Help chat (#1007)
This commit is contained in:
244
src/components/HelpBotDialog.tsx
Normal file
244
src/components/HelpBotDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
|
SparklesIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
@@ -22,6 +23,8 @@ import { useAtomValue } from "jotai";
|
|||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||||
import { ChatLogsData } from "@/ipc/ipc_types";
|
import { ChatLogsData } from "@/ipc/ipc_types";
|
||||||
import { showError } from "@/lib/toast";
|
import { showError } from "@/lib/toast";
|
||||||
|
import { HelpBotDialog } from "./HelpBotDialog";
|
||||||
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
|
|
||||||
interface HelpDialogProps {
|
interface HelpDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -35,7 +38,11 @@ export function HelpDialog({ isOpen, onClose }: HelpDialogProps) {
|
|||||||
const [chatLogsData, setChatLogsData] = useState<ChatLogsData | null>(null);
|
const [chatLogsData, setChatLogsData] = useState<ChatLogsData | null>(null);
|
||||||
const [uploadComplete, setUploadComplete] = useState(false);
|
const [uploadComplete, setUploadComplete] = useState(false);
|
||||||
const [sessionId, setSessionId] = useState("");
|
const [sessionId, setSessionId] = useState("");
|
||||||
|
const [isHelpBotOpen, setIsHelpBotOpen] = useState(false);
|
||||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const isDyadProUser = settings?.providerSettings?.["auto"]?.apiKey?.value;
|
||||||
|
|
||||||
// Function to reset all dialog state
|
// Function to reset all dialog state
|
||||||
const resetDialogState = () => {
|
const resetDialogState = () => {
|
||||||
@@ -373,22 +380,41 @@ Session ID: ${sessionId}
|
|||||||
If you need help or want to report an issue, here are some options:
|
If you need help or want to report an issue, here are some options:
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<div className="flex flex-col space-y-4 w-full">
|
<div className="flex flex-col space-y-4 w-full">
|
||||||
<div className="flex flex-col space-y-2">
|
{isDyadProUser ? (
|
||||||
<Button
|
<div className="flex flex-col space-y-2">
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => {
|
variant="default"
|
||||||
IpcClient.getInstance().openExternalUrl(
|
onClick={() => {
|
||||||
"https://www.dyad.sh/docs",
|
setIsHelpBotOpen(true);
|
||||||
);
|
}}
|
||||||
}}
|
className="w-full py-6 border-primary/50 shadow-sm shadow-primary/10 transition-all hover:shadow-md hover:shadow-primary/15"
|
||||||
className="w-full py-6 bg-(--background-lightest)"
|
>
|
||||||
>
|
<SparklesIcon className="mr-2 h-5 w-5" /> Chat with Dyad help
|
||||||
<BookOpenIcon className="mr-2 h-5 w-5" /> Open Docs
|
bot (Pro)
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground px-2">
|
<p className="text-sm text-muted-foreground px-2">
|
||||||
Get help with common questions and issues.
|
Opens an in-app help chat assistant that searches through Dyad's
|
||||||
</p>
|
docs.
|
||||||
</div>
|
</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">
|
<div className="flex flex-col space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -422,6 +448,10 @@ Session ID: ${sessionId}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<HelpBotDialog
|
||||||
|
isOpen={isHelpBotOpen}
|
||||||
|
onClose={() => setIsHelpBotOpen(false)}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/components/LoadingBlock.tsx
Normal file
136
src/components/LoadingBlock.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
134
src/ipc/handlers/help_bot_handlers.ts
Normal file
134
src/ipc/handlers/help_bot_handlers.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -104,10 +104,19 @@ export class IpcClient {
|
|||||||
private ipcRenderer: IpcRenderer;
|
private ipcRenderer: IpcRenderer;
|
||||||
private chatStreams: Map<number, ChatStreamCallbacks>;
|
private chatStreams: Map<number, ChatStreamCallbacks>;
|
||||||
private appStreams: Map<number, AppStreamCallbacks>;
|
private appStreams: Map<number, AppStreamCallbacks>;
|
||||||
|
private helpStreams: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
onChunk: (delta: string) => void;
|
||||||
|
onEnd: () => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer;
|
this.ipcRenderer = (window as any).electron.ipcRenderer as IpcRenderer;
|
||||||
this.chatStreams = new Map();
|
this.chatStreams = new Map();
|
||||||
this.appStreams = new Map();
|
this.appStreams = new Map();
|
||||||
|
this.helpStreams = new Map();
|
||||||
// Set up listeners for stream events
|
// Set up listeners for stream events
|
||||||
this.ipcRenderer.on("chat:response:chunk", (data) => {
|
this.ipcRenderer.on("chat:response:chunk", (data) => {
|
||||||
if (
|
if (
|
||||||
@@ -180,6 +189,48 @@ export class IpcClient {
|
|||||||
console.error("[IPC] Invalid error data received:", error);
|
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 {
|
public static getInstance(): IpcClient {
|
||||||
@@ -1089,4 +1140,28 @@ export class IpcClient {
|
|||||||
public async deletePrompt(id: number): Promise<void> {
|
public async deletePrompt(id: number): Promise<void> {
|
||||||
await this.ipcRenderer.invoke("prompts:delete", id);
|
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(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
|
|||||||
import { registerTemplateHandlers } from "./handlers/template_handlers";
|
import { registerTemplateHandlers } from "./handlers/template_handlers";
|
||||||
import { registerPortalHandlers } from "./handlers/portal_handlers";
|
import { registerPortalHandlers } from "./handlers/portal_handlers";
|
||||||
import { registerPromptHandlers } from "./handlers/prompt_handlers";
|
import { registerPromptHandlers } from "./handlers/prompt_handlers";
|
||||||
|
import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
|
||||||
|
|
||||||
export function registerIpcHandlers() {
|
export function registerIpcHandlers() {
|
||||||
// Register all IPC handlers by category
|
// Register all IPC handlers by category
|
||||||
@@ -63,4 +64,5 @@ export function registerIpcHandlers() {
|
|||||||
registerTemplateHandlers();
|
registerTemplateHandlers();
|
||||||
registerPortalHandlers();
|
registerPortalHandlers();
|
||||||
registerPromptHandlers();
|
registerPromptHandlers();
|
||||||
|
registerHelpBotHandlers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,3 +420,30 @@ export interface RevertVersionParams {
|
|||||||
export type RevertVersionResponse =
|
export type RevertVersionResponse =
|
||||||
| { successMessage: string }
|
| { successMessage: string }
|
||||||
| { warningMessage: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ const validInvokeChannels = [
|
|||||||
"restart-dyad",
|
"restart-dyad",
|
||||||
"get-templates",
|
"get-templates",
|
||||||
"portal:migrate-create",
|
"portal:migrate-create",
|
||||||
|
// Help bot
|
||||||
|
"help:chat:start",
|
||||||
|
"help:chat:cancel",
|
||||||
// Prompts
|
// Prompts
|
||||||
"prompts:list",
|
"prompts:list",
|
||||||
"prompts:create",
|
"prompts:create",
|
||||||
@@ -128,6 +131,10 @@ const validReceiveChannels = [
|
|||||||
"github:flow-success",
|
"github:flow-success",
|
||||||
"github:flow-error",
|
"github:flow-error",
|
||||||
"deep-link-received",
|
"deep-link-received",
|
||||||
|
// Help bot
|
||||||
|
"help:chat:response:chunk",
|
||||||
|
"help:chat:response:end",
|
||||||
|
"help:chat:response:error",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ValidInvokeChannel = (typeof validInvokeChannels)[number];
|
type ValidInvokeChannel = (typeof validInvokeChannels)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user