feat(agent): 支持持久化 /btw 旁路提问
This commit is contained in:
1 parent
8dae851b9b
commit
555539bc8e
34 files changed
+3744
-314
No files matched your search
@@ -0,0 +1,231 @@
|
||||
"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";
|
||||
|
||||
export function SideQuestionButton({ side }: { side: SideQuestions }) {
|
||||
if (!side.enabled) return null;
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title="/btw 旁路提问">
|
||||
<MessageCircleQuestionIcon data-icon="inline-start" />
|
||||
旁路提问
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanel({ side, label }: { side: SideQuestions; label: string }) {
|
||||
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: "回答中", completed: "已完成", failed: "失败", cancelled: "已停止", interrupted: "已中断" };
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-background" aria-label="旁路提问面板">
|
||||
<div className="flex items-center gap-2 border-b p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
旁路提问 <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="清空旁路历史"
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label="关闭旁路面板">
|
||||
<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>上下文更新于 {new Date(side.snapshot.captured_at).toLocaleString()}</p>
|
||||
</>
|
||||
) : (
|
||||
"主 Agent 首次运行后即可提问"
|
||||
)}
|
||||
</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)}>
|
||||
加载更早的旁路问答
|
||||
</Button>
|
||||
)}
|
||||
{side.loading && <Skeleton className="h-16 w-full" />}
|
||||
{!side.loading && side.items.length === 0 && (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>随时问一个问题</EmptyTitle>
|
||||
<EmptyDescription>根据当前 Agent 的上下文回答,主任务继续运行。</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()}>
|
||||
上下文 {new Date(item.snapshot_at).toLocaleTimeString()}
|
||||
</time>
|
||||
</div>
|
||||
{item.answer && <Markdown text={item.answer} />}
|
||||
{!item.answer && item.status === "running" && (
|
||||
<p role="status" className="text-muted-foreground text-sm">
|
||||
正在回答…
|
||||
</p>
|
||||
)}
|
||||
{item.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{item.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(side.error || side.snapshot?.reason) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="border-t p-3">
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
aria-label="旁路问题"
|
||||
placeholder="询问当前上下文…"
|
||||
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="block-end">
|
||||
<span className="text-muted-foreground text-xs">独立问答 · 无工具执行</span>
|
||||
{side.running ? (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
variant="destructive"
|
||||
size="icon-xs"
|
||||
onClick={() => void side.stop()}
|
||||
aria-label="停止旁路回答"
|
||||
>
|
||||
<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="发送旁路问题"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<AlertDialog open={confirm} onOpenChange={setConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清空旁路历史?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除当前 Agent 的旁路问答,并停止正在生成的旁路回答。主会话和上下文快照会保留。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void side.clear()}>清空历史</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideQuestionWorkspace({
|
||||
side,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
side: SideQuestions;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
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} />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
<Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}>
|
||||
<DrawerContent className="h-[85svh]">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>旁路提问</DrawerTitle>
|
||||
<DrawerDescription>{label} 的独立问答</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<SidePanel side={side} label={label} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user