This commit is contained in:
Will Chen
2025-08-19 15:31:17 -07:00
committed by GitHub
parent 0cdd13dcbe
commit 34215db141
8 changed files with 671 additions and 16 deletions

View File

@@ -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<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const assistantBufferRef = useRef("");
const reasoningBufferRef = useRef("");
const flushTimerRef = useRef<number | null>(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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Dyad Help Bot</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 h-[480px]">
{error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
<div className="flex items-start gap-2">
<div className="text-destructive text-sm font-medium">
Error:
</div>
<div className="text-destructive text-sm flex-1">{error}</div>
<button
onClick={() => setError(null)}
className="text-destructive hover:text-destructive/80 text-xs"
>
</button>
</div>
</div>
)}
<div className="flex-1 overflow-auto rounded-md border p-3 bg-(--background-lightest)">
{messages.length === 0 ? (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
Ask a question about using Dyad.
</div>
<div className="text-xs text-muted-foreground/70 bg-muted/50 rounded-md p-3">
This conversation may be logged and used to improve the
product. Please do not put any sensitive information in here.
</div>
</div>
) : (
<div className="space-y-3">
{messages.map((m, i) => (
<div key={i}>
{m.role === "user" ? (
<div className="text-right">
<div className="inline-block rounded-lg px-3 py-2 bg-primary text-primary-foreground">
{m.content}
</div>
</div>
) : (
<div className="text-left">
{streaming && i === messages.length - 1 && (
<LoadingBlock
isStreaming={streaming && i === messages.length - 1}
/>
)}
{m.content && (
<div className="inline-block rounded-lg px-3 py-2 bg-muted prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none">
<VanillaMarkdownParser content={m.content} />
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
<div className="flex gap-2">
<input
className="flex-1 h-10 rounded-md border bg-background px-3 text-sm"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your question..."
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<Button onClick={handleSend} disabled={streaming || !input.trim()}>
{streaming ? "Sending..." : "Send"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<ChatLogsData | null>(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:
</DialogDescription>
<div className="flex flex-col space-y-4 w-full">
<div className="flex flex-col space-y-2">
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs",
);
}}
className="w-full py-6 bg-(--background-lightest)"
>
<BookOpenIcon className="mr-2 h-5 w-5" /> Open Docs
</Button>
<p className="text-sm text-muted-foreground px-2">
Get help with common questions and issues.
</p>
</div>
{isDyadProUser ? (
<div className="flex flex-col space-y-2">
<Button
variant="default"
onClick={() => {
setIsHelpBotOpen(true);
}}
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
>
<SparklesIcon className="mr-2 h-5 w-5" /> Chat with Dyad help
bot (Pro)
</Button>
<p className="text-sm text-muted-foreground px-2">
Opens an in-app help chat assistant that searches through Dyad's
docs.
</p>
</div>
) : (
<div className="flex flex-col space-y-2">
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs",
);
}}
className="w-full py-6 bg-(--background-lightest)"
>
<BookOpenIcon className="mr-2 h-5 w-5" /> Open Docs
</Button>
<p className="text-sm text-muted-foreground px-2">
Get help with common questions and issues.
</p>
</div>
)}
<div className="flex flex-col space-y-2">
<Button
@@ -422,6 +448,10 @@ Session ID: ${sessionId}
</div>
</div>
</DialogContent>
<HelpBotDialog
isOpen={isHelpBotOpen}
onClose={() => setIsHelpBotOpen(false)}
/>
</Dialog>
);
}

View File

@@ -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;
}) => (
<a
{...props}
onClick={(e) => {
const url = props.href;
if (url) {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(url);
}
}}
/>
);
export const VanillaMarkdownParser = ({ content }: { content: string }) => {
return (
<ReactMarkdown
components={{
a: customLink,
}}
>
{content}
</ReactMarkdown>
);
};
// 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 (
<span
key={index}
style={{ opacity }}
className={isEmoji ? "inline-block animate-emoji-bounce" : ""}
>
{char}
</span>
);
});
};
return (
<div className="flex flex-col items-center justify-center p-4">
<style>{`
@keyframes blink { from, to { opacity: 0 } 50% { opacity: 1 } }
@keyframes emoji-bounce { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-2px) } }
@keyframes text-pulse { 0%, 100% { opacity: .85 } 50% { opacity: 1 } }
.animate-blink { animation: blink 1s steps(2, start) infinite; }
.animate-emoji-bounce { animation: emoji-bounce 1.2s ease-in-out infinite; }
.animate-text-pulse { animation: text-pulse 1.8s ease-in-out infinite; }
`}</style>
<div className="text-center animate-text-pulse">
<div className="inline-block">
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">
{renderFadingText()}
<span className="ml-1 inline-block w-2 h-4 bg-gray-500 dark:bg-gray-400 animate-blink" />
</p>
</div>
</div>
</div>
);
}
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 <ChatLoader />;
}

View File

@@ -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<string, HelpMessage[]>();
const activeHelpStreams = new Map<string, AbortController>();
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;
});
}

View File

@@ -104,10 +104,19 @@ export class IpcClient {
private ipcRenderer: IpcRenderer;
private chatStreams: Map<number, ChatStreamCallbacks>;
private appStreams: Map<number, AppStreamCallbacks>;
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<void> {
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(() => {});
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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];