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,
|
||||
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,6 +380,24 @@ 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">
|
||||
{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"
|
||||
@@ -389,6 +414,7 @@ Session ID: ${sessionId}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user