From 34215db141ddb42495325aebc644d17a509c9754 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Tue, 19 Aug 2025 15:31:17 -0700 Subject: [PATCH] Help chat (#1007) --- src/components/HelpBotDialog.tsx | 244 ++++++++++++++++++++++++++ src/components/HelpDialog.tsx | 62 +++++-- src/components/LoadingBlock.tsx | 136 ++++++++++++++ src/ipc/handlers/help_bot_handlers.ts | 134 ++++++++++++++ src/ipc/ipc_client.ts | 75 ++++++++ src/ipc/ipc_host.ts | 2 + src/ipc/ipc_types.ts | 27 +++ src/preload.ts | 7 + 8 files changed, 671 insertions(+), 16 deletions(-) create mode 100644 src/components/HelpBotDialog.tsx create mode 100644 src/components/LoadingBlock.tsx create mode 100644 src/ipc/handlers/help_bot_handlers.ts diff --git a/src/components/HelpBotDialog.tsx b/src/components/HelpBotDialog.tsx new file mode 100644 index 0000000..13aaa01 --- /dev/null +++ b/src/components/HelpBotDialog.tsx @@ -0,0 +1,244 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { IpcClient } from "@/ipc/ipc_client"; +import { v4 as uuidv4 } from "uuid"; +import { LoadingBlock, VanillaMarkdownParser } from "@/components/LoadingBlock"; + +interface HelpBotDialogProps { + isOpen: boolean; + onClose: () => void; +} + +interface Message { + role: "user" | "assistant"; + content: string; + reasoning?: string; +} + +export function HelpBotDialog({ isOpen, onClose }: HelpBotDialogProps) { + const [input, setInput] = useState(""); + const [messages, setMessages] = useState([]); + const [streaming, setStreaming] = useState(false); + const [error, setError] = useState(null); + const assistantBufferRef = useRef(""); + const reasoningBufferRef = useRef(""); + const flushTimerRef = useRef(null); + const FLUSH_INTERVAL_MS = 100; + + const sessionId = useMemo(() => uuidv4(), [isOpen]); + + useEffect(() => { + if (!isOpen) { + // Clean up when dialog closes + setMessages([]); + setInput(""); + setError(null); + assistantBufferRef.current = ""; + reasoningBufferRef.current = ""; + + // Clear the flush timer + if (flushTimerRef.current) { + window.clearInterval(flushTimerRef.current); + flushTimerRef.current = null; + } + } + }, [isOpen]); + + // Cleanup on component unmount + useEffect(() => { + return () => { + // Clear the flush timer on unmount + if (flushTimerRef.current) { + window.clearInterval(flushTimerRef.current); + flushTimerRef.current = null; + } + }; + }, []); + + const handleSend = async () => { + const trimmed = input.trim(); + if (!trimmed || streaming) return; + setError(null); // Clear any previous errors + setMessages((prev) => [ + ...prev, + { role: "user", content: trimmed }, + { role: "assistant", content: "", reasoning: "" }, + ]); + assistantBufferRef.current = ""; + reasoningBufferRef.current = ""; + setInput(""); + setStreaming(true); + + IpcClient.getInstance().startHelpChat(sessionId, trimmed, { + onChunk: (delta) => { + // Buffer assistant content; UI will flush on interval for smoothness + assistantBufferRef.current += delta; + }, + onEnd: () => { + // Final flush then stop streaming + setMessages((prev) => { + const next = [...prev]; + const lastIdx = next.length - 1; + if (lastIdx >= 0 && next[lastIdx].role === "assistant") { + next[lastIdx] = { + ...next[lastIdx], + content: assistantBufferRef.current, + reasoning: reasoningBufferRef.current, + }; + } + return next; + }); + setStreaming(false); + if (flushTimerRef.current) { + window.clearInterval(flushTimerRef.current); + flushTimerRef.current = null; + } + }, + onError: (errorMessage: string) => { + setError(errorMessage); + setStreaming(false); + + // Clear the flush timer + if (flushTimerRef.current) { + window.clearInterval(flushTimerRef.current); + flushTimerRef.current = null; + } + + // Clear the buffers + assistantBufferRef.current = ""; + reasoningBufferRef.current = ""; + + // Remove the empty assistant message that was added optimistically + setMessages((prev) => { + const next = [...prev]; + if ( + next.length > 0 && + next[next.length - 1].role === "assistant" && + !next[next.length - 1].content + ) { + next.pop(); + } + return next; + }); + }, + }); + + // Start smooth flush interval + if (flushTimerRef.current) { + window.clearInterval(flushTimerRef.current); + } + flushTimerRef.current = window.setInterval(() => { + setMessages((prev) => { + const next = [...prev]; + const lastIdx = next.length - 1; + if (lastIdx >= 0 && next[lastIdx].role === "assistant") { + const current = next[lastIdx]; + // Only update if there's any new data to apply + if ( + current.content !== assistantBufferRef.current || + current.reasoning !== reasoningBufferRef.current + ) { + next[lastIdx] = { + ...current, + content: assistantBufferRef.current, + reasoning: reasoningBufferRef.current, + }; + } + } + return next; + }); + }, FLUSH_INTERVAL_MS); + }; + + return ( + + + + Dyad Help Bot + +
+ {error && ( +
+
+
+ Error: +
+
{error}
+ +
+
+ )} +
+ {messages.length === 0 ? ( +
+
+ Ask a question about using Dyad. +
+
+ This conversation may be logged and used to improve the + product. Please do not put any sensitive information in here. +
+
+ ) : ( +
+ {messages.map((m, i) => ( +
+ {m.role === "user" ? ( +
+
+ {m.content} +
+
+ ) : ( +
+ {streaming && i === messages.length - 1 && ( + + )} + + {m.content && ( +
+ +
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ setInput(e.target.value)} + placeholder="Type your question..." + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + /> + +
+
+
+
+ ); +} diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index fe3adcc..27c4948 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -15,6 +15,7 @@ import { CheckIcon, XIcon, FileIcon, + SparklesIcon, } from "lucide-react"; import { IpcClient } from "@/ipc/ipc_client"; import { useState, useEffect } from "react"; @@ -22,6 +23,8 @@ import { useAtomValue } from "jotai"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { ChatLogsData } from "@/ipc/ipc_types"; import { showError } from "@/lib/toast"; +import { HelpBotDialog } from "./HelpBotDialog"; +import { useSettings } from "@/hooks/useSettings"; interface HelpDialogProps { isOpen: boolean; @@ -35,7 +38,11 @@ export function HelpDialog({ isOpen, onClose }: HelpDialogProps) { const [chatLogsData, setChatLogsData] = useState(null); const [uploadComplete, setUploadComplete] = useState(false); const [sessionId, setSessionId] = useState(""); + const [isHelpBotOpen, setIsHelpBotOpen] = useState(false); const selectedChatId = useAtomValue(selectedChatIdAtom); + const { settings } = useSettings(); + + const isDyadProUser = settings?.providerSettings?.["auto"]?.apiKey?.value; // Function to reset all dialog state const resetDialogState = () => { @@ -373,22 +380,41 @@ Session ID: ${sessionId} If you need help or want to report an issue, here are some options:
-
- -

- Get help with common questions and issues. -

-
+ {isDyadProUser ? ( +
+ +

+ Opens an in-app help chat assistant that searches through Dyad's + docs. +

+
+ ) : ( +
+ +

+ Get help with common questions and issues. +

+
+ )}
+ setIsHelpBotOpen(false)} + /> ); } diff --git a/src/components/LoadingBlock.tsx b/src/components/LoadingBlock.tsx new file mode 100644 index 0000000..ce9d650 --- /dev/null +++ b/src/components/LoadingBlock.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { IpcClient } from "@/ipc/ipc_client"; + +const customLink = ({ + node: _node, + ...props +}: { + node?: any; + [key: string]: any; +}) => ( + { + const url = props.href; + if (url) { + e.preventDefault(); + IpcClient.getInstance().openExternalUrl(url); + } + }} + /> +); + +export const VanillaMarkdownParser = ({ content }: { content: string }) => { + return ( + + {content} + + ); +}; + +// Chat loader with human-like typing/deleting of rotating messages +function ChatLoader() { + const [currentTextIndex, setCurrentTextIndex] = useState(0); + const [displayText, setDisplayText] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + const [typingSpeed, setTypingSpeed] = useState(100); + + const loadingTexts = [ + "Preparing your conversation... πŸ—¨οΈ", + "Gathering thoughts... πŸ’­", + "Crafting the perfect response... 🎨", + "Almost there... πŸš€", + "Just a moment... ⏳", + "Warming up the neural networks... 🧠", + "Connecting the dots... πŸ”—", + "Brewing some digital magic... ✨", + "Assembling words with care... πŸ”€", + "Fine-tuning the response... 🎯", + "Diving into deep thought... 🀿", + "Weaving ideas together... πŸ•ΈοΈ", + "Sparking up the conversation... ⚑", + "Polishing the perfect reply... πŸ’Ž", + ]; + + useEffect(() => { + const currentText = loadingTexts[currentTextIndex]; + const timer = window.setTimeout(() => { + if (!isDeleting) { + if (displayText.length < currentText.length) { + setDisplayText(currentText.substring(0, displayText.length + 1)); + const randomSpeed = Math.random() * 50 + 30; + const isLongPause = Math.random() > 0.85; + setTypingSpeed(isLongPause ? 300 : randomSpeed); + } else { + setTypingSpeed(1500); + setIsDeleting(true); + } + } else { + if (displayText.length > 0) { + setDisplayText(currentText.substring(0, displayText.length - 1)); + setTypingSpeed(30); + } else { + setIsDeleting(false); + setCurrentTextIndex((prev) => (prev + 1) % loadingTexts.length); + setTypingSpeed(500); + } + } + }, typingSpeed); + return () => window.clearTimeout(timer); + }, [displayText, isDeleting, currentTextIndex, typingSpeed]); + + const renderFadingText = () => { + return displayText.split("").map((char, index) => { + const opacity = Math.min( + 0.8 + (index / (displayText.length || 1)) * 0.2, + 1, + ); + const isEmoji = /\p{Emoji}/u.test(char); + return ( + + {char} + + ); + }); + }; + + return ( +
+ +
+
+

+ {renderFadingText()} + +

+
+
+
+ ); +} + +interface LoadingBlockProps { + isStreaming?: boolean; +} + +// Instead of showing raw thinking content, render the chat loader while streaming. +export function LoadingBlock({ isStreaming = false }: LoadingBlockProps) { + if (!isStreaming) return null; + return ; +} diff --git a/src/ipc/handlers/help_bot_handlers.ts b/src/ipc/handlers/help_bot_handlers.ts new file mode 100644 index 0000000..6e7b2eb --- /dev/null +++ b/src/ipc/handlers/help_bot_handlers.ts @@ -0,0 +1,134 @@ +import { ipcMain } from "electron"; +import { streamText } from "ai"; +import { readSettings } from "../../main/settings"; + +import log from "electron-log"; +import { safeSend } from "../utils/safe_sender"; +import { + createOpenAI, + openai, + OpenAIResponsesProviderOptions, +} from "@ai-sdk/openai"; +import { StartHelpChatParams } from "../ipc_types"; + +const logger = log.scope("help-bot"); + +// In-memory session store for help bot conversations +type HelpMessage = { role: "user" | "assistant"; content: string }; +const helpSessions = new Map(); +const activeHelpStreams = new Map(); + +export function registerHelpBotHandlers() { + ipcMain.handle( + "help:chat:start", + async (event, params: StartHelpChatParams) => { + const { sessionId, message } = params; + try { + if (!sessionId || !message?.trim()) { + throw new Error("Missing sessionId or message"); + } + + // Clear any existing active streams (only one session at a time) + for (const [existingSessionId, controller] of activeHelpStreams) { + controller.abort(); + activeHelpStreams.delete(existingSessionId); + helpSessions.delete(existingSessionId); + } + + // Append user message to session history + const history = helpSessions.get(sessionId) ?? []; + const updatedHistory: HelpMessage[] = [ + ...history, + { role: "user", content: message }, + ]; + + const abortController = new AbortController(); + activeHelpStreams.set(sessionId, abortController); + const settings = await readSettings(); + const apiKey = settings.providerSettings?.["auto"]?.apiKey?.value; + const provider = createOpenAI({ + baseURL: "https://helpchat.dyad.sh/v1", + apiKey, + }); + + let assistantContent = ""; + + const stream = streamText({ + model: provider.responses("gpt-5-nano"), + providerOptions: { + openai: { + reasoningSummary: "auto", + } satisfies OpenAIResponsesProviderOptions, + }, + tools: { + web_search_preview: openai.tools.webSearchPreview({ + searchContextSize: "high", + }), + }, + messages: updatedHistory as any, + maxRetries: 1, + onError: (error) => { + let errorMessage = (error as any)?.error?.message; + logger.error("help bot stream error", errorMessage); + safeSend(event.sender, "help:chat:response:error", { + sessionId, + error: String(errorMessage), + }); + }, + }); + + (async () => { + try { + for await (const part of stream.fullStream) { + if (abortController.signal.aborted) break; + + if (part.type === "text-delta") { + assistantContent += part.text; + safeSend(event.sender, "help:chat:response:chunk", { + sessionId, + delta: part.text, + type: "text", + }); + } + } + + // Finalize session history + const finalHistory: HelpMessage[] = [ + ...updatedHistory, + { role: "assistant", content: assistantContent }, + ]; + helpSessions.set(sessionId, finalHistory); + + safeSend(event.sender, "help:chat:response:end", { sessionId }); + } catch (err) { + if ((err as any)?.name === "AbortError") { + logger.log("help bot stream aborted", sessionId); + return; + } + logger.error("help bot stream loop error", err); + safeSend(event.sender, "help:chat:response:error", { + sessionId, + error: String(err instanceof Error ? err.message : err), + }); + } finally { + activeHelpStreams.delete(sessionId); + } + })(); + + return { ok: true } as const; + } catch (err) { + logger.error("help:chat:start error", err); + throw err instanceof Error ? err : new Error(String(err)); + } + }, + ); + + ipcMain.handle("help:chat:cancel", async (_event, sessionId: string) => { + const controller = activeHelpStreams.get(sessionId); + if (controller) { + controller.abort(); + activeHelpStreams.delete(sessionId); + } + return { ok: true } as const; + }); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 3177913..e3510ed 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -104,10 +104,19 @@ export class IpcClient { private ipcRenderer: IpcRenderer; private chatStreams: Map; private appStreams: Map; + private helpStreams: Map< + string, + { + onChunk: (delta: string) => void; + onEnd: () => void; + onError: (error: string) => void; + } + >; private constructor() { this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer; this.chatStreams = new Map(); this.appStreams = new Map(); + this.helpStreams = new Map(); // Set up listeners for stream events this.ipcRenderer.on("chat:response:chunk", (data) => { if ( @@ -180,6 +189,48 @@ export class IpcClient { console.error("[IPC] Invalid error data received:", error); } }); + + // Help bot events + this.ipcRenderer.on("help:chat:response:chunk", (data) => { + if ( + data && + typeof data === "object" && + "sessionId" in data && + "delta" in data + ) { + const { sessionId, delta } = data as { + sessionId: string; + delta: string; + }; + const callbacks = this.helpStreams.get(sessionId); + if (callbacks) callbacks.onChunk(delta); + } + }); + + this.ipcRenderer.on("help:chat:response:end", (data) => { + if (data && typeof data === "object" && "sessionId" in data) { + const { sessionId } = data as { sessionId: string }; + const callbacks = this.helpStreams.get(sessionId); + if (callbacks) callbacks.onEnd(); + this.helpStreams.delete(sessionId); + } + }); + this.ipcRenderer.on("help:chat:response:error", (data) => { + if ( + data && + typeof data === "object" && + "sessionId" in data && + "error" in data + ) { + const { sessionId, error } = data as { + sessionId: string; + error: string; + }; + const callbacks = this.helpStreams.get(sessionId); + if (callbacks) callbacks.onError(error); + this.helpStreams.delete(sessionId); + } + }); } public static getInstance(): IpcClient { @@ -1089,4 +1140,28 @@ export class IpcClient { public async deletePrompt(id: number): Promise { await this.ipcRenderer.invoke("prompts:delete", id); } + + // --- Help bot --- + public startHelpChat( + sessionId: string, + message: string, + options: { + onChunk: (delta: string) => void; + onEnd: () => void; + onError: (error: string) => void; + }, + ): void { + this.helpStreams.set(sessionId, options); + this.ipcRenderer + .invoke("help:chat:start", { sessionId, message }) + .catch((err) => { + this.helpStreams.delete(sessionId); + showError(err); + options.onError(String(err)); + }); + } + + public cancelHelpChat(sessionId: string): void { + this.ipcRenderer.invoke("help:chat:cancel", sessionId).catch(() => {}); + } } diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index e4e12ae..d9cbc41 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -29,6 +29,7 @@ import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; import { registerTemplateHandlers } from "./handlers/template_handlers"; import { registerPortalHandlers } from "./handlers/portal_handlers"; import { registerPromptHandlers } from "./handlers/prompt_handlers"; +import { registerHelpBotHandlers } from "./handlers/help_bot_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -63,4 +64,5 @@ export function registerIpcHandlers() { registerTemplateHandlers(); registerPortalHandlers(); registerPromptHandlers(); + registerHelpBotHandlers(); } diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 269fdd6..75a9499 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -420,3 +420,30 @@ export interface RevertVersionParams { export type RevertVersionResponse = | { successMessage: string } | { warningMessage: string }; + +// --- Help Bot Types --- +export interface StartHelpChatParams { + sessionId: string; + message: string; +} + +export interface HelpChatResponseChunk { + sessionId: string; + delta: string; + type: "text"; +} + +export interface HelpChatResponseReasoning { + sessionId: string; + delta: string; + type: "reasoning"; +} + +export interface HelpChatResponseEnd { + sessionId: string; +} + +export interface HelpChatResponseError { + sessionId: string; + error: string; +} diff --git a/src/preload.ts b/src/preload.ts index 5882a2d..7b18a10 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -106,6 +106,9 @@ const validInvokeChannels = [ "restart-dyad", "get-templates", "portal:migrate-create", + // Help bot + "help:chat:start", + "help:chat:cancel", // Prompts "prompts:list", "prompts:create", @@ -128,6 +131,10 @@ const validReceiveChannels = [ "github:flow-success", "github:flow-error", "deep-link-received", + // Help bot + "help:chat:response:chunk", + "help:chat:response:end", + "help:chat:response:error", ] as const; type ValidInvokeChannel = (typeof validInvokeChannels)[number];