mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 15:19:00 +03:00
GenAI Refactor (#23253)
* Ensure runtime options are passed * Add attribute info to prompt when configured * Move GenAI plugins to dedicated directory * Migrate prompts to dedicated folder * Move chat prompts to prompts * Implement reasoning traces in the UI * Cleanup * Make azure a subclass of openai * Implement reasoning for other providers * mypy * Cleanup
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuBrain, LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ReasoningBubbleProps = {
|
||||
/** The accumulated reasoning text from the model. */
|
||||
reasoning: string;
|
||||
/**
|
||||
* Whether the assistant has begun producing the user-facing answer.
|
||||
* While false the reasoning is still streaming and we keep the panel
|
||||
* open with a "Thinking…" label. Once true, the panel auto-collapses
|
||||
* so the answer is the primary focus, but stays expandable.
|
||||
*/
|
||||
answerStarted: boolean;
|
||||
};
|
||||
|
||||
export function ReasoningBubble({
|
||||
reasoning,
|
||||
answerStarted,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
// Open while the model is still mid-thought (no answer tokens yet);
|
||||
// once the answer begins, collapse on its own but let the user reopen.
|
||||
const [open, setOpen] = useState(true);
|
||||
const userInteractedRef = useRef(false);
|
||||
const lastAutoState = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (userInteractedRef.current) return;
|
||||
const desired = !answerStarted;
|
||||
if (desired !== lastAutoState.current) {
|
||||
lastAutoState.current = desired;
|
||||
setOpen(desired);
|
||||
}
|
||||
}, [answerStarted]);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
userInteractedRef.current = true;
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const label = !answerStarted
|
||||
? t("reasoning.active")
|
||||
: open
|
||||
? t("reasoning.hide")
|
||||
: t("reasoning.show");
|
||||
|
||||
return (
|
||||
<div className="self-start rounded-2xl bg-muted/60 px-3 py-2 text-muted-foreground">
|
||||
<Collapsible open={open} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto w-full min-w-0 justify-start gap-2 whitespace-normal p-0 text-left text-xs hover:bg-transparent"
|
||||
>
|
||||
<LuBrain
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
!answerStarted && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
<span className="break-words font-medium">{label}</span>
|
||||
{answerStarted &&
|
||||
(open ? (
|
||||
<LuChevronDown className="ml-auto size-3 shrink-0" />
|
||||
) : (
|
||||
<LuChevronRight className="ml-auto size-3 shrink-0" />
|
||||
))}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="scrollbar-container mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-sans text-xs leading-relaxed">
|
||||
{reasoning}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-4
@@ -7,6 +7,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import axios from "axios";
|
||||
import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow";
|
||||
import { MessageBubble } from "@/components/chat/ChatMessage";
|
||||
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
|
||||
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
|
||||
import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
@@ -200,15 +201,21 @@ export default function ChatPage() {
|
||||
const hasToolCalls =
|
||||
msg.toolCalls && msg.toolCalls.length > 0;
|
||||
const hasContent = !!msg.content?.trim();
|
||||
const hasReasoning = !!msg.reasoning?.trim();
|
||||
const showProcessing =
|
||||
isLastAssistant && isLoading && !hasContent;
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasReasoning;
|
||||
|
||||
// Hide empty placeholder only when there are no tool calls yet
|
||||
// Hide empty placeholder only when there are no tool calls
|
||||
// and no reasoning streaming yet
|
||||
if (
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasToolCalls
|
||||
!hasToolCalls &&
|
||||
!hasReasoning
|
||||
)
|
||||
return (
|
||||
<div
|
||||
@@ -226,13 +233,22 @@ export default function ChatPage() {
|
||||
{msg.role === "assistant" && hasToolCalls && (
|
||||
<ToolCallsGroup toolCalls={msg.toolCalls!} />
|
||||
)}
|
||||
{msg.role === "assistant" && hasReasoning && (
|
||||
<ReasoningBubble
|
||||
reasoning={msg.reasoning!}
|
||||
answerStarted={hasContent}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
) : msg.role === "assistant" &&
|
||||
!hasContent &&
|
||||
hasReasoning &&
|
||||
!isComplete ? null : (
|
||||
<MessageBubble
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ToolCall = {
|
||||
export type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
stats?: ChatStats;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ type StreamChunk =
|
||||
| { type: "error"; error: string }
|
||||
| { type: "tool_calls"; tool_calls: ToolCall[] }
|
||||
| { type: "content"; delta: string }
|
||||
| { type: "reasoning"; delta: string }
|
||||
| StatsChunk;
|
||||
|
||||
/**
|
||||
@@ -109,6 +110,19 @@ export async function streamChatCompletion(
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "reasoning" && data.delta !== undefined) {
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = {
|
||||
...lastMsg,
|
||||
reasoning: (lastMsg.reasoning ?? "") + data.delta,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "stats") {
|
||||
const stats: ChatStats = {
|
||||
promptTokens: data.prompt_tokens,
|
||||
|
||||
Reference in New Issue
Block a user