Compare commits
3 Commits
bf080eed88
...
e059442d53
| Author | SHA1 | Date | |
|---|---|---|---|
| e059442d53 | |||
| 67f96fff03 | |||
| 4498a3f666 |
@ -4,6 +4,7 @@ import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useAIChat } from "@/features/binary/hooks/useAIChat";
|
||||
import { analyzingMessages } from "@/features/binary/analyzingMessages";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@ -19,6 +20,46 @@ const quickPrompts = [
|
||||
"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() {
|
||||
const {
|
||||
binary,
|
||||
@ -29,6 +70,8 @@ export function AIChatPanel() {
|
||||
isLoadingMessages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolStatus,
|
||||
isToolRunning,
|
||||
streamError,
|
||||
isCreatingSession,
|
||||
createSession,
|
||||
@ -43,6 +86,21 @@ export function AIChatPanel() {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -215,8 +273,6 @@ export function AIChatPanel() {
|
||||
<>
|
||||
{messages.map((message, idx) => {
|
||||
const isUser = message.role === "USER";
|
||||
const isLastMessage = idx === messages.length - 1;
|
||||
const isStreamingMessage = isLastMessage && isStreaming && !message.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -245,67 +301,13 @@ export function AIChatPanel() {
|
||||
>
|
||||
{isUser ? (
|
||||
<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
|
||||
remarkPlugins={[remarkGfm]}
|
||||
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}
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{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 ?? "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/([^\n])\n([^\n])/g, "$1 \n$2")}
|
||||
{normalizeMarkdown(message.content ?? "")}
|
||||
</ReactMarkdown>
|
||||
</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>
|
||||
)}
|
||||
|
||||
52
src/features/binary/analyzingMessages.ts
Normal file
52
src/features/binary/analyzingMessages.ts
Normal 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",
|
||||
];
|
||||
@ -10,6 +10,46 @@ import {
|
||||
} from "@/api/generated/chat/chat.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() {
|
||||
const params = useParams();
|
||||
const binaryId = params.binaryId!;
|
||||
@ -20,7 +60,11 @@ export function useAIChat() {
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
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 [optimisticAssistantMessage, setOptimisticAssistantMessage] =
|
||||
useState<MessageResponse | null>(null);
|
||||
|
||||
const { data: binary } = useGetBinary(binaryId, {
|
||||
query: { enabled: !!binaryId },
|
||||
@ -89,6 +133,9 @@ export function useAIChat() {
|
||||
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
setOptimisticAssistantMessage(null);
|
||||
setStreamError(null);
|
||||
setPendingUserMessage({
|
||||
role: "USER",
|
||||
@ -137,23 +184,53 @@ export function useAIChat() {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const event = JSON.parse(line.slice(5).trim());
|
||||
|
||||
if (event.type === "chunk") {
|
||||
fullContent += event.content;
|
||||
const sseEvent = event as SSEEvent;
|
||||
switch (sseEvent.type) {
|
||||
case "chunk":
|
||||
fullContent += sseEvent.content;
|
||||
setStreamingContent(fullContent);
|
||||
} else if (event.type === "done") {
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
break;
|
||||
case "tool_call_start":
|
||||
setToolStatus(`Calling ${sseEvent.tool}...`);
|
||||
setIsToolRunning(true);
|
||||
break;
|
||||
case "tool_call_end":
|
||||
setToolStatus(
|
||||
`${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
|
||||
);
|
||||
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("");
|
||||
setPendingUserMessage(null);
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [`/binaries/${binaryId}/chat/sessions`],
|
||||
});
|
||||
} else if (event.type === "error") {
|
||||
break;
|
||||
case "error":
|
||||
setIsStreaming(false);
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
setPendingUserMessage(null);
|
||||
setStreamError(event.message);
|
||||
setOptimisticAssistantMessage(null);
|
||||
setStreamError(sseEvent.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -164,23 +241,53 @@ export function useAIChat() {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const event = JSON.parse(line.slice(5).trim());
|
||||
|
||||
if (event.type === "chunk") {
|
||||
fullContent += event.content;
|
||||
const sseEvent = event as SSEEvent;
|
||||
switch (sseEvent.type) {
|
||||
case "chunk":
|
||||
fullContent += sseEvent.content;
|
||||
setStreamingContent(fullContent);
|
||||
} else if (event.type === "done") {
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
break;
|
||||
case "tool_call_start":
|
||||
setToolStatus(`Calling ${sseEvent.tool}...`);
|
||||
setIsToolRunning(true);
|
||||
break;
|
||||
case "tool_call_end":
|
||||
setToolStatus(
|
||||
`${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
|
||||
);
|
||||
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("");
|
||||
setPendingUserMessage(null);
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [`/binaries/${binaryId}/chat/sessions`],
|
||||
});
|
||||
} else if (event.type === "error") {
|
||||
break;
|
||||
case "error":
|
||||
setIsStreaming(false);
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
setPendingUserMessage(null);
|
||||
setStreamError(event.message);
|
||||
setOptimisticAssistantMessage(null);
|
||||
setStreamError(sseEvent.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -190,7 +297,10 @@ export function useAIChat() {
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setIsStreaming(false);
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
setPendingUserMessage(null);
|
||||
setOptimisticAssistantMessage(null);
|
||||
setStreamError(err.message || "An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
@ -201,17 +311,28 @@ export function useAIChat() {
|
||||
const cancelStream = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setIsStreaming(false);
|
||||
setToolStatus(null);
|
||||
setIsToolRunning(false);
|
||||
setPendingUserMessage(null);
|
||||
setOptimisticAssistantMessage(null);
|
||||
}, []);
|
||||
|
||||
const messages: MessageResponse[] = [...historicalMessages];
|
||||
if (isStreaming && pendingUserMessage) {
|
||||
|
||||
if (
|
||||
pendingUserMessage &&
|
||||
!historicalMessages.some((m) => m.role === "USER" && m.content === pendingUserMessage.content)
|
||||
) {
|
||||
messages.push(pendingUserMessage);
|
||||
messages.push({
|
||||
role: "ASSISTANT",
|
||||
content: streamingContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
optimisticAssistantMessage &&
|
||||
!historicalMessages.some(
|
||||
(m) => m.role === "ASSISTANT" && m.content === optimisticAssistantMessage.content
|
||||
)
|
||||
) {
|
||||
messages.push(optimisticAssistantMessage);
|
||||
}
|
||||
|
||||
const userFriendlyError = streamError
|
||||
@ -231,6 +352,8 @@ export function useAIChat() {
|
||||
isLoadingMessages: messagesQuery.isLoading,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolStatus,
|
||||
isToolRunning,
|
||||
streamError: userFriendlyError,
|
||||
isCreatingSession: createSessionMutation.isPending,
|
||||
createSession,
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-success: var(--success);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user