Compare commits

..

3 Commits

4 changed files with 314 additions and 100 deletions

View File

@ -4,6 +4,7 @@ import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { useAIChat } from "@/features/binary/hooks/useAIChat"; import { useAIChat } from "@/features/binary/hooks/useAIChat";
import { analyzingMessages } from "@/features/binary/analyzingMessages";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@ -19,6 +20,46 @@ const quickPrompts = [
"Summarize the binary", "Summarize the binary",
]; ];
const remarkPlugins = [remarkGfm];
const markdownContainerClass =
"[&_th]:border-border [&_td]:border-border max-w-none break-words [&_ol]:my-2 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_pre]:my-2 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2";
const markdownComponents: ComponentPropsWithoutRef<typeof ReactMarkdown>["components"] = {
code({
className,
children,
...props
}: ComponentPropsWithoutRef<"code"> & {
className?: string;
}) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
const codeString = String(children).replace(/\n$/, "");
if (isInline) {
return (
<code className="bg-muted-foreground/20 rounded px-1 py-0.5 text-xs break-all" {...props}>
{children}
</code>
);
}
return (
<pre className="bg-muted-foreground/10 max-w-full overflow-x-auto rounded-lg p-3 text-xs">
<code className="break-all whitespace-pre-wrap">{codeString}</code>
</pre>
);
},
pre({ children }) {
return <pre className="my-2 overflow-x-auto rounded-lg">{children}</pre>;
},
};
function normalizeMarkdown(content: string) {
return content.replace(/\r\n/g, "\n").replace(/([^\n])\n([^\n])/g, "$1 \n$2");
}
export function AIChatPanel() { export function AIChatPanel() {
const { const {
binary, binary,
@ -29,6 +70,8 @@ export function AIChatPanel() {
isLoadingMessages, isLoadingMessages,
isStreaming, isStreaming,
streamingContent, streamingContent,
toolStatus,
isToolRunning,
streamError, streamError,
isCreatingSession, isCreatingSession,
createSession, createSession,
@ -43,6 +86,21 @@ export function AIChatPanel() {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [userScrolledUp, setUserScrolledUp] = useState(false); const [userScrolledUp, setUserScrolledUp] = useState(false);
const [analyzingIdx, setAnalyzingIdx] = useState(() =>
Math.floor(Math.random() * analyzingMessages.length)
);
useEffect(() => {
if (!isStreaming || streamingContent) return;
const interval = setInterval(() => {
setAnalyzingIdx(Math.floor(Math.random() * analyzingMessages.length));
}, 5000);
return () => clearInterval(interval);
}, [isStreaming, streamingContent]);
const analyzingMessage = analyzingMessages[analyzingIdx];
// Track manual scroll to respect user reading position // Track manual scroll to respect user reading position
useEffect(() => { useEffect(() => {
@ -215,8 +273,6 @@ export function AIChatPanel() {
<> <>
{messages.map((message, idx) => { {messages.map((message, idx) => {
const isUser = message.role === "USER"; const isUser = message.role === "USER";
const isLastMessage = idx === messages.length - 1;
const isStreamingMessage = isLastMessage && isStreaming && !message.id;
return ( return (
<div <div
@ -245,67 +301,13 @@ export function AIChatPanel() {
> >
{isUser ? ( {isUser ? (
<div className="break-words whitespace-pre-wrap">{message.content}</div> <div className="break-words whitespace-pre-wrap">{message.content}</div>
) : isStreamingMessage ? (
<div className="break-words whitespace-pre-wrap">
{streamingContent ? (
<>
{streamingContent}
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-current align-middle" />
</>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Analyzing...</span>
</div>
)}
</div>
) : ( ) : (
<div className="[&_th]:border-border [&_td]:border-border max-w-none break-words [&_ol]:my-2 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_pre]:my-2 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2"> <div className={markdownContainerClass}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={remarkPlugins}
components={{ components={markdownComponents}
code({
className,
children,
...props
}: ComponentPropsWithoutRef<"code"> & {
className?: string;
}) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
const codeString = String(children).replace(/\n$/, "");
if (isInline) {
return (
<code
className="bg-muted-foreground/20 rounded px-1 py-0.5 text-xs break-all"
{...props}
>
{children}
</code>
);
}
return (
<pre className="bg-muted-foreground/10 max-w-full overflow-x-auto rounded-lg p-3 text-xs">
<code className="break-all whitespace-pre-wrap">
{codeString}
</code>
</pre>
);
},
pre({ children }) {
return (
<pre className="my-2 overflow-x-auto rounded-lg">
{children}
</pre>
);
},
}}
> >
{(message.content ?? "") {normalizeMarkdown(message.content ?? "")}
.replace(/\r\n/g, "\n")
.replace(/([^\n])\n([^\n])/g, "$1 \n$2")}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@ -323,6 +325,42 @@ export function AIChatPanel() {
})} })}
</> </>
)} )}
{isStreaming && (
<>
<div className="mb-4 flex gap-3">
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Bot className="h-4 w-4" />
</div>
<div className="flex max-w-[85%] min-w-0 flex-col items-start gap-1">
<div className="bg-muted overflow-hidden rounded-lg px-3 py-2 text-sm">
{streamingContent ? (
<div className={markdownContainerClass}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={markdownComponents}
>
{normalizeMarkdown(streamingContent)}
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="text-primary h-4 w-4 animate-spin" />
<span>{analyzingMessage}...</span>
</div>
)}
</div>
</div>
</div>
{toolStatus && (
<div className="text-muted-foreground bg-muted border-l-primary my-1 flex items-center gap-2 rounded-lg border-l-[3px] px-3 py-2 text-sm">
{isToolRunning && (
<Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
)}
<span>{toolStatus}</span>
</div>
)}
</>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
)} )}

View File

@ -0,0 +1,52 @@
export const analyzingMessages = [
"Analyzing",
"Examining",
"Decompiling",
"Decoding",
"Mapping",
"Tracing",
"Extracting",
"Processing",
"Verifying",
"Comparing",
"Indexing",
"Scanning",
"Interpreting",
"Rebuilding",
"Inspecting",
"Classifying",
"Resolving",
"Checking",
"Searching",
"Navigating",
"Cross-referencing",
"Validating",
"Cataloging",
"Filtering",
"Grouping",
"Deobfuscating",
"Unpacking",
"Loading",
"Parsing",
"Translating",
"Optimizing",
"Symbolizing",
"Linking",
"Disassembling",
"Reconstructing",
"Profiling",
"Fuzzing",
"Auditing",
"Monitoring",
"Computing",
"Iterating",
"Deserializing",
"Normalizing",
"Hashing",
"Enumerating",
"Unwinding",
"Patching",
"Diffing",
"Emulating",
"Tainting",
];

View File

@ -10,6 +10,46 @@ import {
} from "@/api/generated/chat/chat.ts"; } from "@/api/generated/chat/chat.ts";
import type { SessionResponse, MessageResponse } from "@/api/generated/api.schemas.ts"; import type { SessionResponse, MessageResponse } from "@/api/generated/api.schemas.ts";
interface ChunkEvent {
type: "chunk";
content: string;
}
interface ToolCallStartEvent {
type: "tool_call_start";
tool: string;
}
interface ToolCallEndEvent {
type: "tool_call_end";
tool: string;
durationMs: number;
resultSummary: string;
}
interface ThinkingEvent {
type: "thinking";
message: string;
}
interface DoneEvent {
type: "done";
sessionId: string;
}
interface ErrorEvent {
type: "error";
message: string;
}
type SSEEvent =
| ChunkEvent
| ToolCallStartEvent
| ToolCallEndEvent
| ThinkingEvent
| DoneEvent
| ErrorEvent;
export function useAIChat() { export function useAIChat() {
const params = useParams(); const params = useParams();
const binaryId = params.binaryId!; const binaryId = params.binaryId!;
@ -20,7 +60,11 @@ export function useAIChat() {
const [streamingContent, setStreamingContent] = useState(""); const [streamingContent, setStreamingContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null); const [streamError, setStreamError] = useState<string | null>(null);
const [toolStatus, setToolStatus] = useState<string | null>(null);
const [isToolRunning, setIsToolRunning] = useState(false);
const [pendingUserMessage, setPendingUserMessage] = useState<MessageResponse | null>(null); const [pendingUserMessage, setPendingUserMessage] = useState<MessageResponse | null>(null);
const [optimisticAssistantMessage, setOptimisticAssistantMessage] =
useState<MessageResponse | null>(null);
const { data: binary } = useGetBinary(binaryId, { const { data: binary } = useGetBinary(binaryId, {
query: { enabled: !!binaryId }, query: { enabled: !!binaryId },
@ -89,6 +133,9 @@ export function useAIChat() {
setIsStreaming(true); setIsStreaming(true);
setStreamingContent(""); setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
setOptimisticAssistantMessage(null);
setStreamError(null); setStreamError(null);
setPendingUserMessage({ setPendingUserMessage({
role: "USER", role: "USER",
@ -137,23 +184,53 @@ export function useAIChat() {
if (!line.startsWith("data:")) continue; if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim()); const event = JSON.parse(line.slice(5).trim());
if (event.type === "chunk") { const sseEvent = event as SSEEvent;
fullContent += event.content; switch (sseEvent.type) {
setStreamingContent(fullContent); case "chunk":
} else if (event.type === "done") { fullContent += sseEvent.content;
setIsStreaming(false); setStreamingContent(fullContent);
setStreamingContent(""); setToolStatus(null);
setPendingUserMessage(null); setIsToolRunning(false);
queryClient.invalidateQueries({ break;
queryKey: [`/chat/sessions/${activeSessionId}/messages`], case "tool_call_start":
}); setToolStatus(`Calling ${sseEvent.tool}...`);
queryClient.invalidateQueries({ setIsToolRunning(true);
queryKey: [`/binaries/${binaryId}/chat/sessions`], break;
}); case "tool_call_end":
} else if (event.type === "error") { setToolStatus(
setIsStreaming(false); `${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
setPendingUserMessage(null); );
setStreamError(event.message); setIsToolRunning(false);
break;
case "thinking":
setToolStatus(sseEvent.message);
setIsToolRunning(true);
break;
case "done":
setOptimisticAssistantMessage({
role: "ASSISTANT",
content: fullContent,
createdAt: new Date().toISOString(),
});
setIsStreaming(false);
setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
queryClient.invalidateQueries({
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
});
queryClient.invalidateQueries({
queryKey: [`/binaries/${binaryId}/chat/sessions`],
});
break;
case "error":
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
setOptimisticAssistantMessage(null);
setStreamError(sseEvent.message);
break;
} }
} }
} }
@ -164,23 +241,53 @@ export function useAIChat() {
if (!line.startsWith("data:")) continue; if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim()); const event = JSON.parse(line.slice(5).trim());
if (event.type === "chunk") { const sseEvent = event as SSEEvent;
fullContent += event.content; switch (sseEvent.type) {
setStreamingContent(fullContent); case "chunk":
} else if (event.type === "done") { fullContent += sseEvent.content;
setIsStreaming(false); setStreamingContent(fullContent);
setStreamingContent(""); setToolStatus(null);
setPendingUserMessage(null); setIsToolRunning(false);
queryClient.invalidateQueries({ break;
queryKey: [`/chat/sessions/${activeSessionId}/messages`], case "tool_call_start":
}); setToolStatus(`Calling ${sseEvent.tool}...`);
queryClient.invalidateQueries({ setIsToolRunning(true);
queryKey: [`/binaries/${binaryId}/chat/sessions`], break;
}); case "tool_call_end":
} else if (event.type === "error") { setToolStatus(
setIsStreaming(false); `${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
setPendingUserMessage(null); );
setStreamError(event.message); setIsToolRunning(false);
break;
case "thinking":
setToolStatus(sseEvent.message);
setIsToolRunning(true);
break;
case "done":
setOptimisticAssistantMessage({
role: "ASSISTANT",
content: fullContent,
createdAt: new Date().toISOString(),
});
setIsStreaming(false);
setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
queryClient.invalidateQueries({
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
});
queryClient.invalidateQueries({
queryKey: [`/binaries/${binaryId}/chat/sessions`],
});
break;
case "error":
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
setOptimisticAssistantMessage(null);
setStreamError(sseEvent.message);
break;
} }
} }
} }
@ -190,7 +297,10 @@ export function useAIChat() {
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") { if (err instanceof Error && err.name !== "AbortError") {
setIsStreaming(false); setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null); setPendingUserMessage(null);
setOptimisticAssistantMessage(null);
setStreamError(err.message || "An unexpected error occurred"); setStreamError(err.message || "An unexpected error occurred");
} }
} }
@ -201,17 +311,28 @@ export function useAIChat() {
const cancelStream = useCallback(() => { const cancelStream = useCallback(() => {
abortRef.current?.abort(); abortRef.current?.abort();
setIsStreaming(false); setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null); setPendingUserMessage(null);
setOptimisticAssistantMessage(null);
}, []); }, []);
const messages: MessageResponse[] = [...historicalMessages]; const messages: MessageResponse[] = [...historicalMessages];
if (isStreaming && pendingUserMessage) {
if (
pendingUserMessage &&
!historicalMessages.some((m) => m.role === "USER" && m.content === pendingUserMessage.content)
) {
messages.push(pendingUserMessage); messages.push(pendingUserMessage);
messages.push({ }
role: "ASSISTANT",
content: streamingContent, if (
createdAt: new Date().toISOString(), optimisticAssistantMessage &&
}); !historicalMessages.some(
(m) => m.role === "ASSISTANT" && m.content === optimisticAssistantMessage.content
)
) {
messages.push(optimisticAssistantMessage);
} }
const userFriendlyError = streamError const userFriendlyError = streamError
@ -231,6 +352,8 @@ export function useAIChat() {
isLoadingMessages: messagesQuery.isLoading, isLoadingMessages: messagesQuery.isLoading,
isStreaming, isStreaming,
streamingContent, streamingContent,
toolStatus,
isToolRunning,
streamError: userFriendlyError, streamError: userFriendlyError,
isCreatingSession: createSessionMutation.isPending, isCreatingSession: createSessionMutation.isPending,
createSession, createSession,

View File

@ -28,6 +28,7 @@
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-success: var(--success);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);