mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
|
|
import { ArrowUpIcon, MessageCircleQuestionIcon, SquareIcon, Trash2Icon, XIcon } from "lucide-react";
|
|
|
|
import { Markdown } from "@/components/markdown";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import type { SideQuestions } from "@/hooks/use-side-questions";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type ComposerLayout = "inline" | "stacked";
|
|
|
|
const preparationLabels = {
|
|
preparing: "Preparing context…",
|
|
summarizing_history: "Summarizing earlier side Q&A…",
|
|
compressing_snapshot: "Compressing the side-question context copy…",
|
|
retrying: "Model context exceeded, shrinking and retrying…",
|
|
answering: "Answering…",
|
|
};
|
|
|
|
export function SideQuestionButton({ side }: { side: SideQuestions }) {
|
|
if (!side.enabled) return null;
|
|
return (
|
|
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title="/btw side question">
|
|
<MessageCircleQuestionIcon data-icon="inline-start" />
|
|
Side question
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function SidePanel({
|
|
side,
|
|
label,
|
|
composerLayout,
|
|
}: {
|
|
side: SideQuestions;
|
|
label: string;
|
|
composerLayout: ComposerLayout;
|
|
}) {
|
|
const inlineComposer = composerLayout === "inline";
|
|
const [confirm, setConfirm] = useState(false);
|
|
const viewport = useRef<HTMLDivElement>(null);
|
|
const pinned = useRef(true);
|
|
const tail = side.items.at(-1);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: New cumulative text scrolls only readers who remain at the bottom.
|
|
useEffect(() => {
|
|
if (pinned.current && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight;
|
|
}, [tail?.answer, tail?.id]);
|
|
const status = { running: "Answering", completed: "Completed", failed: "Failed", cancelled: "Stopped", interrupted: "Interrupted" };
|
|
return (
|
|
<section className="flex h-full min-h-0 flex-col bg-background" aria-label="Side question panel">
|
|
<div className="flex items-center gap-2 border-b p-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">
|
|
Side question <span className="text-muted-foreground">/btw</span>
|
|
</p>
|
|
<p className="truncate text-muted-foreground text-xs">{label}</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setConfirm(true)}
|
|
disabled={!side.items.length || side.busy}
|
|
aria-label="Clear side-question history"
|
|
>
|
|
<Trash2Icon />
|
|
</Button>
|
|
<Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label="Close side-question panel">
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
<div className="border-b px-3 py-2 text-muted-foreground text-xs">
|
|
{side.snapshot ? (
|
|
<>
|
|
<p>{side.snapshot.model.model}</p>
|
|
<p>Context updated {new Date(side.snapshot.captured_at).toLocaleString()}</p>
|
|
</>
|
|
) : (
|
|
"Ask anytime after the main agent's first run"
|
|
)}
|
|
</div>
|
|
<div
|
|
ref={viewport}
|
|
onScroll={(event) => {
|
|
const el = event.currentTarget;
|
|
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
}}
|
|
className="min-h-0 flex-1 overflow-y-auto p-3"
|
|
>
|
|
{side.nextCursor > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={() => void side.load(side.nextCursor)}>
|
|
Load earlier side Q&A
|
|
</Button>
|
|
)}
|
|
{side.loading && <Skeleton className="h-16 w-full" />}
|
|
{!side.loading && side.items.length === 0 && (
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyTitle>Ask a question anytime</EmptyTitle>
|
|
<EmptyDescription>Answered from the current agent's context while the main task keeps running.</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)}
|
|
<div className="flex flex-col gap-5">
|
|
{side.items.map((item) => (
|
|
<article key={item.id} className="flex min-w-0 flex-col gap-2">
|
|
<div className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{item.question}</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
|
|
<Badge variant="secondary">{status[item.status]}</Badge>
|
|
<span className="truncate">{item.model.model}</span>
|
|
<time dateTime={item.snapshot_at} title={new Date(item.snapshot_at).toLocaleString()}>
|
|
Context {new Date(item.snapshot_at).toLocaleTimeString()}
|
|
</time>
|
|
</div>
|
|
{item.context?.estimated_input_tokens != null && (
|
|
<p className="text-muted-foreground text-xs">
|
|
Last {item.context.recent_exchanges} Q&A exchanges verbatim
|
|
{item.context.history_summarized && " · earlier Q&A summarized"}
|
|
{item.context.snapshot_summarized && " · uses the main-context digest"}
|
|
</p>
|
|
)}
|
|
{item.answer && <Markdown text={item.answer} />}
|
|
{!item.answer && item.status === "running" && (
|
|
<p role="status" className="text-muted-foreground text-sm">
|
|
{preparationLabels[item.context?.phase ?? "answering"]}
|
|
</p>
|
|
)}
|
|
{item.error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{item.error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 border-t p-3">
|
|
{(side.error || side.snapshot?.reason) && (
|
|
<Alert variant="destructive" className="mb-2">
|
|
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<InputGroup className={inlineComposer ? "min-h-10" : "min-h-9"}>
|
|
<InputGroupTextarea
|
|
rows={1}
|
|
className={cn("overflow-y-auto", inlineComposer ? "max-h-40 min-h-0" : "max-h-36 min-h-9")}
|
|
aria-label="Side question"
|
|
placeholder="Ask about the current context…"
|
|
value={side.draft}
|
|
maxLength={4000}
|
|
disabled={side.busy}
|
|
onChange={(event) => side.setDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
event.preventDefault();
|
|
void side.ask(side.draft);
|
|
}
|
|
}}
|
|
/>
|
|
<InputGroupAddon align={inlineComposer ? "inline-end" : "block-end"}>
|
|
{!inlineComposer && <span className="text-muted-foreground text-xs">Standalone Q&A · No tool execution</span>}
|
|
{side.running ? (
|
|
<InputGroupButton
|
|
className="ml-auto"
|
|
variant="destructive"
|
|
size="icon-xs"
|
|
onClick={() => void side.stop()}
|
|
aria-label="Stop the side answer"
|
|
>
|
|
<SquareIcon />
|
|
</InputGroupButton>
|
|
) : (
|
|
<InputGroupButton
|
|
className="ml-auto"
|
|
variant="default"
|
|
size="icon-xs"
|
|
onClick={() => void side.ask(side.draft)}
|
|
disabled={side.busy || !side.draft.trim() || !side.snapshot?.available}
|
|
aria-label="Send side question"
|
|
>
|
|
<ArrowUpIcon />
|
|
</InputGroupButton>
|
|
)}
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</div>
|
|
{inlineComposer && (
|
|
<div className="shrink-0 truncate px-3 pt-0.5 pb-1 text-muted-foreground text-xs">Standalone Q&A · No tool execution</div>
|
|
)}
|
|
<AlertDialog open={confirm} onOpenChange={setConfirm}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Clear side-question history?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Deletes this agent's side Q&A and stops any answer still being generated. The main session and context snapshot are kept.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => void side.clear()}>Clear history</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function SideQuestionWorkspace({
|
|
side,
|
|
label,
|
|
children,
|
|
composerLayout = "stacked",
|
|
}: {
|
|
side: SideQuestions;
|
|
label: string;
|
|
children: ReactNode;
|
|
composerLayout?: ComposerLayout;
|
|
}) {
|
|
const mobile = useIsMobile();
|
|
return (
|
|
<>
|
|
<ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1">
|
|
<ResizablePanel id="main-conversation" minSize="35%" className="flex min-h-0 min-w-0 flex-col">
|
|
{children}
|
|
</ResizablePanel>
|
|
{side.open && side.enabled && !mobile && (
|
|
<>
|
|
<ResizableHandle withHandle />
|
|
<ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%">
|
|
<SidePanel side={side} label={label} composerLayout={composerLayout} />
|
|
</ResizablePanel>
|
|
</>
|
|
)}
|
|
</ResizablePanelGroup>
|
|
<Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}>
|
|
<DrawerContent className="h-[85svh]">
|
|
<DrawerHeader className="sr-only">
|
|
<DrawerTitle>Side question</DrawerTitle>
|
|
<DrawerDescription>Standalone Q&A for {label}</DrawerDescription>
|
|
</DrawerHeader>
|
|
<SidePanel side={side} label={label} composerLayout={composerLayout} />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|