mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Add settings to handle token stats and other options
This commit is contained in:
parent
8f07a81ac3
commit
1a3ac6e3cf
@ -6,7 +6,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatEvent = { id: string; score?: number };
|
||||
|
||||
@ -37,10 +36,7 @@ export function ChatEventThumbnailsRow({
|
||||
const renderThumb = (event: ChatEvent, isAnchor = false) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
"relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg",
|
||||
isAnchor && "ring-2 ring-primary",
|
||||
)}
|
||||
className="relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@ -71,9 +67,15 @@ export function ChatEventThumbnailsRow({
|
||||
<TooltipContent>{t("open_in_explore")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isAnchor && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 rounded bg-primary px-1 text-[10px] text-primary-foreground">
|
||||
{t("anchor")}
|
||||
</span>
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-inset ring-primary"
|
||||
/>
|
||||
<span className="pointer-events-none absolute left-1 top-1 rounded bg-primary px-1 text-[10px] text-primary-foreground">
|
||||
{t("anchor")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
108
web/src/components/chat/ChatSettings.tsx
Normal file
108
web/src/components/chat/ChatSettings.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ShowStatsMode } from "@/types/chat";
|
||||
|
||||
type ChatSettingsProps = {
|
||||
showStats: ShowStatsMode;
|
||||
setShowStats: (mode: ShowStatsMode) => void;
|
||||
autoScroll: boolean;
|
||||
setAutoScroll: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ChatSettings({
|
||||
showStats,
|
||||
setShowStats,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
}: ChatSettingsProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("settings.title")}
|
||||
size="sm"
|
||||
>
|
||||
<FaCog className="text-secondary-foreground" />
|
||||
{t("settings.title")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">{t("settings.show_stats.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.show_stats.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={showStats}
|
||||
onValueChange={(v) => setShowStats(v as ShowStatsMode)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
id="show-stats-while-generating"
|
||||
value="while_generating"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="show-stats-while-generating"
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{t("settings.show_stats.while_generating")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="show-stats-always" value="always" />
|
||||
<Label
|
||||
htmlFor="show-stats-always"
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{t("settings.show_stats.always")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
|
||||
{t("settings.auto_scroll.title")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.auto_scroll.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-scroll"
|
||||
checked={autoScroll}
|
||||
onCheckedChange={setAutoScroll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlatformAwareDialog
|
||||
trigger={trigger}
|
||||
content={content}
|
||||
contentClassName={cn(
|
||||
"scrollbar-container h-auto overflow-y-auto",
|
||||
isDesktop ? "max-h-[80dvh] w-72" : "px-4",
|
||||
)}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FaArrowUpLong, FaStop } from "react-icons/fa6";
|
||||
import { LuCircleAlert } from "react-icons/lu";
|
||||
import { LuCircleAlert, LuMessageSquarePlus } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import axios from "axios";
|
||||
@ -12,7 +12,9 @@ import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
|
||||
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton";
|
||||
import type { ChatMessage } from "@/types/chat";
|
||||
import ChatSettings from "@/components/chat/ChatSettings";
|
||||
import type { ChatMessage, ShowStatsMode } from "@/types/chat";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
getEventIdsFromSearchObjectsToolCalls,
|
||||
getFindSimilarObjectsFromToolCalls,
|
||||
@ -27,6 +29,14 @@ export default function ChatPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
||||
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
|
||||
"chat-show-stats",
|
||||
"while_generating",
|
||||
);
|
||||
const [autoScroll, setAutoScroll] = usePersistence<boolean>(
|
||||
"chat-auto-scroll",
|
||||
true,
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@ -36,13 +46,14 @@ export default function ChatPage() {
|
||||
|
||||
// Auto-scroll to bottom when messages change, but only if near bottom
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
|
||||
if (isNearBottom) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, autoScroll]);
|
||||
|
||||
const submitConversation = useCallback(
|
||||
async (messagesToSend: ChatMessage[]) => {
|
||||
@ -125,6 +136,16 @@ export default function ChatPage() {
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachedEventId(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleEditSubmit = useCallback(
|
||||
(messageIndex: number, newContent: string) => {
|
||||
const newList: ChatMessage[] = [
|
||||
@ -140,127 +161,157 @@ export default function ChatPage() {
|
||||
setAttachedEventId(null);
|
||||
}, []);
|
||||
|
||||
const hasStarted = messages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex size-full justify-center p-2 md:p-4">
|
||||
<div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
||||
{messages.length === 0 ? (
|
||||
<ChatStartingState
|
||||
onSendMessage={(message) => {
|
||||
setInput("");
|
||||
submitConversation([{ role: "user", content: message }]);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container flex min-h-0 w-full flex-1 flex-col gap-3 overflow-y-auto"
|
||||
>
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant =
|
||||
i === messages.length - 1 && msg.role === "assistant";
|
||||
const isComplete =
|
||||
msg.role === "user" || !isLoading || !isLastAssistant;
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
const hasContent = !!msg.content?.trim();
|
||||
const showProcessing =
|
||||
isLastAssistant && isLoading && !hasContent;
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 px-2 pt-2 md:px-4 md:pt-4">
|
||||
{hasStarted && (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("new_chat")}
|
||||
size="sm"
|
||||
onClick={startNewChat}
|
||||
>
|
||||
<LuMessageSquarePlus className="text-secondary-foreground" />
|
||||
{t("new_chat")}
|
||||
</Button>
|
||||
)}
|
||||
<ChatSettings
|
||||
showStats={showStats ?? "while_generating"}
|
||||
setShowStats={setShowStats}
|
||||
autoScroll={autoScroll ?? true}
|
||||
setAutoScroll={setAutoScroll}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
<div className="flex flex-1 justify-center px-2 md:px-4">
|
||||
<div className="flex w-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
||||
{hasStarted ? (
|
||||
<div className="flex w-full flex-1 flex-col gap-3 pb-3 pt-3">
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant =
|
||||
i === messages.length - 1 && msg.role === "assistant";
|
||||
const isComplete =
|
||||
msg.role === "user" || !isLoading || !isLastAssistant;
|
||||
const hasToolCalls =
|
||||
msg.toolCalls && msg.toolCalls.length > 0;
|
||||
const hasContent = !!msg.content?.trim();
|
||||
const showProcessing =
|
||||
isLastAssistant && isLoading && !hasContent;
|
||||
|
||||
// Hide empty placeholder only when there are no tool calls yet
|
||||
if (
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasToolCalls
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4"
|
||||
>
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
{msg.role === "assistant" && hasToolCalls && (
|
||||
<ToolCallsGroup toolCalls={msg.toolCalls!} />
|
||||
)}
|
||||
{showProcessing ? (
|
||||
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
// Hide empty placeholder only when there are no tool calls yet
|
||||
if (
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasToolCalls
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4"
|
||||
>
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
) : (
|
||||
<MessageBubble
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={
|
||||
msg.role === "user" ? handleEditSubmit : undefined
|
||||
}
|
||||
isComplete={isComplete}
|
||||
/>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
isComplete &&
|
||||
(() => {
|
||||
const similar = getFindSimilarObjectsFromToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
if (similar) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
{msg.role === "assistant" && hasToolCalls && (
|
||||
<ToolCallsGroup toolCalls={msg.toolCalls!} />
|
||||
)}
|
||||
{showProcessing ? (
|
||||
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
) : (
|
||||
<MessageBubble
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={
|
||||
msg.role === "user" ? handleEditSubmit : undefined
|
||||
}
|
||||
isComplete={isComplete}
|
||||
stats={msg.stats}
|
||||
showStats={showStats}
|
||||
/>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
isComplete &&
|
||||
(() => {
|
||||
const similar = getFindSimilarObjectsFromToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
if (similar) {
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={similar.results}
|
||||
anchor={similar.anchor}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={similar.results}
|
||||
anchor={similar.anchor}
|
||||
events={events}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={events}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<LuCircleAlert className="size-3.5 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<ChatEntry
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
isLoading={isLoading}
|
||||
placeholder={t("placeholder")}
|
||||
attachedEventId={attachedEventId}
|
||||
onClearAttachment={handleClearAttachment}
|
||||
onAttach={setAttachedEventId}
|
||||
onStop={stopGeneration}
|
||||
recentEventIds={recentEventIds}
|
||||
/>
|
||||
)}
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<LuCircleAlert className="size-3.5 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChatStartingState
|
||||
onSendMessage={(message) => {
|
||||
setInput("");
|
||||
submitConversation([{ role: "user", content: message }]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasStarted && (
|
||||
<div className="flex shrink-0 justify-center px-2 pb-2 md:px-4 md:pb-4">
|
||||
<div className="flex w-full xl:w-[50%] 3xl:w-[35%]">
|
||||
<ChatEntry
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
isLoading={isLoading}
|
||||
placeholder={t("placeholder")}
|
||||
attachedEventId={attachedEventId}
|
||||
onClearAttachment={handleClearAttachment}
|
||||
onAttach={setAttachedEventId}
|
||||
onStop={stopGeneration}
|
||||
recentEventIds={recentEventIds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -298,7 +349,7 @@ function ChatEntry({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
{attachedEventId && (
|
||||
<div className="flex items-center">
|
||||
<ChatAttachmentChip
|
||||
|
||||
@ -8,9 +8,19 @@ export type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
stats?: ChatStats;
|
||||
};
|
||||
|
||||
export type StartingRequest = {
|
||||
label: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type ChatStats = {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
completionDurationMs?: number;
|
||||
tokensPerSecond?: number;
|
||||
};
|
||||
|
||||
export type ShowStatsMode = "while_generating" | "always";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ChatMessage, ToolCall } from "@/types/chat";
|
||||
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
|
||||
|
||||
export type StreamChatCallbacks = {
|
||||
/** Update the messages array (e.g. pass to setState). */
|
||||
@ -7,14 +7,27 @@ export type StreamChatCallbacks = {
|
||||
onError: (message: string) => void;
|
||||
/** Called when the stream finishes (success or error). */
|
||||
onDone: () => void;
|
||||
/** Called when the stream emits token/timing stats. The stats are also
|
||||
* attached to the last assistant message in updateMessages, so consumers
|
||||
* can usually rely on the message itself rather than wiring this up. */
|
||||
onStats?: (stats: ChatStats) => void;
|
||||
/** Message used when fetch throws and no server error is available. */
|
||||
defaultErrorMessage?: string;
|
||||
};
|
||||
|
||||
type StatsChunk = {
|
||||
type: "stats";
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
completion_duration_ms?: number;
|
||||
tokens_per_second?: number;
|
||||
};
|
||||
|
||||
type StreamChunk =
|
||||
| { type: "error"; error: string }
|
||||
| { type: "tool_calls"; tool_calls: ToolCall[] }
|
||||
| { type: "content"; delta: string };
|
||||
| { type: "content"; delta: string }
|
||||
| StatsChunk;
|
||||
|
||||
/**
|
||||
* POST to chat/completion with stream: true, parse NDJSON stream, and invoke
|
||||
@ -31,6 +44,7 @@ export async function streamChatCompletion(
|
||||
updateMessages,
|
||||
onError,
|
||||
onDone,
|
||||
onStats,
|
||||
defaultErrorMessage = "Something went wrong. Please try again.",
|
||||
} = callbacks;
|
||||
|
||||
@ -95,6 +109,23 @@ export async function streamChatCompletion(
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "stats") {
|
||||
const stats: ChatStats = {
|
||||
promptTokens: data.prompt_tokens,
|
||||
completionTokens: data.completion_tokens,
|
||||
completionDurationMs: data.completion_duration_ms,
|
||||
tokensPerSecond: data.tokens_per_second,
|
||||
};
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = { ...lastMsg, stats };
|
||||
return next;
|
||||
});
|
||||
onStats?.(stats);
|
||||
return "continue";
|
||||
}
|
||||
return "continue";
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user