feat(agent): 支持持久化 /btw 旁路提问

This commit is contained in:
ji ruo committed 2026-09-10 21:42:25 +08:00
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>
</>
);
}