fix(btw): 修复长对话上下文预算与旁路输入布局
This commit is contained in:
1 parent
555539bc8e
commit
d893861d00
17 files changed
+1295
-92
No files matched your search
@@ -253,15 +253,17 @@ function LLMProfileRow({
|
||||
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1 pb-1 pt-0.5">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1 px-1 pt-0.5 pb-1">
|
||||
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
|
||||
<span className="text-muted-foreground/70 text-xs">{label}</span>
|
||||
<span className="truncate text-muted-foreground/70 text-xs" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-primary flex items-center gap-0.5 text-xs hover:underline disabled:pointer-events-none disabled:opacity-40"
|
||||
className="flex shrink-0 items-center gap-0.5 text-primary text-xs hover:underline disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
更换
|
||||
<ChevronDownIcon className="size-3" />
|
||||
@@ -689,7 +691,7 @@ function ChatView({
|
||||
}
|
||||
|
||||
return (
|
||||
<SideQuestionWorkspace side={side} label={agent?.name ?? conv.agent_key}>
|
||||
<SideQuestionWorkspace side={side} label={agent?.name ?? conv.agent_key} composerLayout="inline">
|
||||
{/* header: which agent + live + token meta */}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-4 py-2.5">
|
||||
<Bot className="text-muted-foreground size-4 shrink-0" />
|
||||
|
||||
@@ -25,6 +25,17 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
|
||||
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: "正在准备上下文…",
|
||||
summarizing_history: "正在整理早期旁路问答…",
|
||||
compressing_snapshot: "正在压缩旁路上下文副本…",
|
||||
retrying: "模型上下文超限,正在缩减后重试…",
|
||||
answering: "正在回答…",
|
||||
};
|
||||
|
||||
export function SideQuestionButton({ side }: { side: SideQuestions }) {
|
||||
if (!side.enabled) return null;
|
||||
@@ -36,7 +47,16 @@ export function SideQuestionButton({ side }: { side: SideQuestions }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanel({ side, label }: { side: SideQuestions; label: string }) {
|
||||
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);
|
||||
@@ -111,10 +131,17 @@ function SidePanel({ side, label }: { side: SideQuestions; label: string }) {
|
||||
上下文 {new Date(item.snapshot_at).toLocaleTimeString()}
|
||||
</time>
|
||||
</div>
|
||||
{item.context?.estimated_input_tokens != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
最近 {item.context.recent_exchanges} 组问答原文
|
||||
{item.context.history_summarized && " · 含早期问答摘要"}
|
||||
{item.context.snapshot_summarized && " · 使用主上下文摘要"}
|
||||
</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 && (
|
||||
@@ -126,14 +153,16 @@ function SidePanel({ side, label }: { side: SideQuestions; label: string }) {
|
||||
))}
|
||||
</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>
|
||||
<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="旁路问题"
|
||||
placeholder="询问当前上下文…"
|
||||
value={side.draft}
|
||||
@@ -147,8 +176,8 @@ function SidePanel({ side, label }: { side: SideQuestions; label: string }) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="block-end">
|
||||
<span className="text-muted-foreground text-xs">独立问答 · 无工具执行</span>
|
||||
<InputGroupAddon align={inlineComposer ? "inline-end" : "block-end"}>
|
||||
{!inlineComposer && <span className="text-muted-foreground text-xs">独立问答 · 无工具执行</span>}
|
||||
{side.running ? (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
@@ -174,6 +203,9 @@ function SidePanel({ side, label }: { side: SideQuestions; label: string }) {
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{inlineComposer && (
|
||||
<div className="shrink-0 truncate px-3 pt-0.5 pb-1 text-muted-foreground text-xs">独立问答 · 无工具执行</div>
|
||||
)}
|
||||
<AlertDialog open={confirm} onOpenChange={setConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -196,10 +228,12 @@ export function SideQuestionWorkspace({
|
||||
side,
|
||||
label,
|
||||
children,
|
||||
composerLayout = "stacked",
|
||||
}: {
|
||||
side: SideQuestions;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
composerLayout?: ComposerLayout;
|
||||
}) {
|
||||
const mobile = useIsMobile();
|
||||
return (
|
||||
@@ -212,7 +246,7 @@ export function SideQuestionWorkspace({
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%">
|
||||
<SidePanel side={side} label={label} />
|
||||
<SidePanel side={side} label={label} composerLayout={composerLayout} />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
@@ -223,7 +257,7 @@ export function SideQuestionWorkspace({
|
||||
<DrawerTitle>旁路提问</DrawerTitle>
|
||||
<DrawerDescription>{label} 的独立问答</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<SidePanel side={side} label={label} />
|
||||
<SidePanel side={side} label={label} composerLayout={composerLayout} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
|
||||
@@ -26,14 +26,27 @@ export function useSideQuestions(parent: string | null) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nextCursor, setNextCursor] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const epoch = useRef(0);
|
||||
const [streamEpoch, setStreamEpoch] = useState(0);
|
||||
const cursor = useRef(0);
|
||||
const submitting = useRef(false);
|
||||
const retry = useRef<{ question: string; id: string } | null>(null);
|
||||
const accepted = useRef<{ id: string; question: string } | null>(null);
|
||||
const running = current ? items.find((item) => item.status === "running") : undefined;
|
||||
const runningID = running?.id;
|
||||
|
||||
const restoreFailedDraft = useCallback((incoming: SideExchange[]) => {
|
||||
const pending = accepted.current;
|
||||
if (!pending) return;
|
||||
const item = incoming.find((entry) => entry.id === pending.id);
|
||||
if (!item || item.status === "running") return;
|
||||
accepted.current = null;
|
||||
if (item.status === "failed" || item.status === "interrupted") {
|
||||
setDraft((old) => old || pending.question);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (before = 0) => {
|
||||
if (!parent) return;
|
||||
@@ -42,17 +55,18 @@ export function useSideQuestions(parent: string | null) {
|
||||
const data = await sideAPI.history(parent, before);
|
||||
if (version !== epoch.current) return;
|
||||
setItems((old) => merge(old, data.items));
|
||||
restoreFailedDraft(data.items);
|
||||
setSnapshot(data.snapshot);
|
||||
setError("");
|
||||
setLoadError("");
|
||||
if (before || !cursor.current) {
|
||||
cursor.current = data.next_cursor;
|
||||
setNextCursor(data.next_cursor);
|
||||
}
|
||||
} catch (err) {
|
||||
if (version === epoch.current) setError((err as Error).message);
|
||||
if (version === epoch.current) setLoadError((err as Error).message);
|
||||
}
|
||||
},
|
||||
[parent],
|
||||
[parent, restoreFailedDraft],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,9 +77,11 @@ export function useSideQuestions(parent: string | null) {
|
||||
setNextCursor(0);
|
||||
setDraft("");
|
||||
setError("");
|
||||
setLoadError("");
|
||||
setBusy(false);
|
||||
submitting.current = false;
|
||||
retry.current = null;
|
||||
accepted.current = null;
|
||||
cursor.current = 0;
|
||||
if (!parent) {
|
||||
setOpen(false);
|
||||
@@ -112,6 +128,7 @@ export function useSideQuestions(parent: string | null) {
|
||||
const item = JSON.parse((event as MessageEvent).data) as SideExchange;
|
||||
if (item.id !== runningID) return;
|
||||
setItems((old) => merge(old, [item]));
|
||||
restoreFailedDraft([item]);
|
||||
if (item.status !== "running") stream.close();
|
||||
} catch {
|
||||
setError("旁路数据解析失败,请重新打开面板");
|
||||
@@ -122,7 +139,7 @@ export function useSideQuestions(parent: string | null) {
|
||||
if (version === epoch.current) setItems([]);
|
||||
});
|
||||
return () => stream.close();
|
||||
}, [runningID, streamEpoch]);
|
||||
}, [runningID, streamEpoch, restoreFailedDraft]);
|
||||
|
||||
const ask = async (input: string) => {
|
||||
const question = input.trim();
|
||||
@@ -130,6 +147,7 @@ export function useSideQuestions(parent: string | null) {
|
||||
const version = epoch.current;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setDraft(question);
|
||||
setOpen(true);
|
||||
if (retry.current?.question !== question) retry.current = { question, id: crypto.randomUUID() };
|
||||
@@ -137,6 +155,7 @@ export function useSideQuestions(parent: string | null) {
|
||||
const item = await sideAPI.ask(parent, question, retry.current.id);
|
||||
if (version !== epoch.current) return;
|
||||
setItems((old) => merge(old, [item]));
|
||||
accepted.current = { id: item.id, question };
|
||||
setDraft("");
|
||||
setError("");
|
||||
retry.current = null;
|
||||
@@ -144,7 +163,6 @@ export function useSideQuestions(parent: string | null) {
|
||||
} catch (err) {
|
||||
if (version === epoch.current) {
|
||||
setError((err as Error).message);
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
if (version === epoch.current) {
|
||||
@@ -179,6 +197,7 @@ export function useSideQuestions(parent: string | null) {
|
||||
cursor.current = 0;
|
||||
setError("");
|
||||
retry.current = null;
|
||||
accepted.current = null;
|
||||
} catch (err) {
|
||||
if (version === epoch.current) toast.error((err as Error).message);
|
||||
} finally {
|
||||
@@ -209,7 +228,7 @@ export function useSideQuestions(parent: string | null) {
|
||||
setDraft,
|
||||
busy: current && busy,
|
||||
loading: !current || loading,
|
||||
error: current ? error : "",
|
||||
error: current ? error || loadError : "",
|
||||
running,
|
||||
nextCursor: current ? nextCursor : 0,
|
||||
load,
|
||||
|
||||
@@ -18,6 +18,16 @@ export interface SideExchange {
|
||||
snapshot_at: string;
|
||||
created_at: string;
|
||||
sequence: number;
|
||||
context?: {
|
||||
phase?: "preparing" | "summarizing_history" | "compressing_snapshot" | "retrying" | "answering";
|
||||
recent_exchanges: number;
|
||||
history_summarized: boolean;
|
||||
snapshot_summarized: boolean;
|
||||
estimated_input_tokens?: number;
|
||||
input_budget?: number;
|
||||
output_tokens?: number;
|
||||
overflow_retried?: boolean;
|
||||
};
|
||||
}
|
||||
export interface SideHistory {
|
||||
items: SideExchange[];
|
||||
|
||||
Reference in new issue
Block a user