mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-30 09:01:14 +03:00
Cleanup chat page rendering
This commit is contained in:
parent
a122cb36d7
commit
7d66d063aa
@ -13,6 +13,7 @@ import { ChatComposer } from "@/components/chat/ChatComposer";
|
|||||||
import ChatSettings from "@/components/chat/ChatSettings";
|
import ChatSettings from "@/components/chat/ChatSettings";
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
ChatStats,
|
||||||
GenAIModelsResponse,
|
GenAIModelsResponse,
|
||||||
ShowStatsMode,
|
ShowStatsMode,
|
||||||
} from "@/types/chat";
|
} from "@/types/chat";
|
||||||
@ -22,12 +23,28 @@ import {
|
|||||||
getFindSimilarObjectsFromToolCalls,
|
getFindSimilarObjectsFromToolCalls,
|
||||||
prependAttachment,
|
prependAttachment,
|
||||||
streamChatCompletion,
|
streamChatCompletion,
|
||||||
|
toolCallsForMessage,
|
||||||
|
toolResponsesById,
|
||||||
} from "@/utils/chatUtil";
|
} from "@/utils/chatUtil";
|
||||||
|
|
||||||
|
type StreamingTurn = {
|
||||||
|
content: string;
|
||||||
|
reasoning: string;
|
||||||
|
turn: ChatMessage[];
|
||||||
|
stats?: ChatStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasText = (content: unknown): content is string =>
|
||||||
|
typeof content === "string" && content.trim().length > 0;
|
||||||
|
|
||||||
|
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
|
||||||
|
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { t } = useTranslation(["views/chat"]);
|
const { t } = useTranslation(["views/chat"]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [streaming, setStreaming] = useState<StreamingTurn | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
||||||
@ -72,28 +89,19 @@ export default function ChatPage() {
|
|||||||
if (isNearBottom) {
|
if (isNearBottom) {
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [messages, autoScroll]);
|
}, [messages, streaming, autoScroll]);
|
||||||
|
|
||||||
const submitConversation = useCallback(
|
const submitConversation = useCallback(
|
||||||
async (messagesToSend: ChatMessage[]) => {
|
async (messagesToSend: ChatMessage[]) => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
const last = messagesToSend[messagesToSend.length - 1];
|
const last = messagesToSend[messagesToSend.length - 1];
|
||||||
if (!last || last.role !== "user" || !last.content.trim()) return;
|
if (!last || last.role !== "user" || !hasText(last.content)) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
const assistantPlaceholder: ChatMessage = {
|
setMessages(messagesToSend);
|
||||||
role: "assistant",
|
setStreaming({ content: "", reasoning: "", turn: [] });
|
||||||
content: "",
|
|
||||||
toolCalls: undefined,
|
|
||||||
};
|
|
||||||
setMessages([...messagesToSend, assistantPlaceholder]);
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const apiMessages = messagesToSend.map((m) => ({
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const baseURL = axios.defaults.baseURL ?? "";
|
const baseURL = axios.defaults.baseURL ?? "";
|
||||||
const url = `${baseURL}chat/completion`;
|
const url = `${baseURL}chat/completion`;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@ -104,16 +112,49 @@ export default function ChatPage() {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
let turn: ChatMessage[] = [];
|
||||||
|
let stats: ChatStats | undefined;
|
||||||
|
let reasoning = "";
|
||||||
|
let hadError = false;
|
||||||
|
|
||||||
await streamChatCompletion(
|
await streamChatCompletion(
|
||||||
url,
|
url,
|
||||||
headers,
|
headers,
|
||||||
apiMessages,
|
toWire(messagesToSend),
|
||||||
{
|
{
|
||||||
updateMessages: (updater) => setMessages(updater),
|
onContentDelta: (delta) =>
|
||||||
onError: (message) => setError(message),
|
setStreaming((s) => (s ? { ...s, content: s.content + delta } : s)),
|
||||||
|
onReasoningDelta: (delta) => {
|
||||||
|
reasoning += delta;
|
||||||
|
setStreaming((s) =>
|
||||||
|
s ? { ...s, reasoning: s.reasoning + delta } : s,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onTurnMessages: (turnMessages) => {
|
||||||
|
turn = turnMessages;
|
||||||
|
setStreaming((s) => (s ? { ...s, turn: turnMessages } : s));
|
||||||
|
},
|
||||||
|
onStats: (s) => {
|
||||||
|
stats = s;
|
||||||
|
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
hadError = true;
|
||||||
|
setError(message);
|
||||||
|
},
|
||||||
onDone: () => {
|
onDone: () => {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setStreaming(null);
|
||||||
|
const lastMsg = turn[turn.length - 1];
|
||||||
|
if (!hadError && lastMsg?.role === "assistant") {
|
||||||
|
const committed = turn.map((m, i) =>
|
||||||
|
i === turn.length - 1
|
||||||
|
? { ...m, reasoning: reasoning || undefined, stats }
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
setMessages((prev) => [...prev, ...committed]);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaultErrorMessage: t("error"),
|
defaultErrorMessage: t("error"),
|
||||||
},
|
},
|
||||||
@ -125,12 +166,14 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const recentEventIds = useMemo(() => {
|
const recentEventIds = useMemo(() => {
|
||||||
|
const responses = toolResponsesById(messages);
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
if (msg.role !== "assistant" || !msg.toolCalls) continue;
|
if (msg.role !== "assistant" || !msg.tool_calls?.length) continue;
|
||||||
const similar = getFindSimilarObjectsFromToolCalls(msg.toolCalls);
|
const calls = toolCallsForMessage(msg, responses);
|
||||||
|
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||||
if (similar) return similar.results.map((e) => e.id);
|
if (similar) return similar.results.map((e) => e.id);
|
||||||
const events = getEventIdsFromSearchObjectsToolCalls(msg.toolCalls);
|
const events = getEventIdsFromSearchObjectsToolCalls(calls);
|
||||||
if (events.length > 0) return events.map((e) => e.id);
|
if (events.length > 0) return events.map((e) => e.id);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@ -154,12 +197,14 @@ export default function ChatPage() {
|
|||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setStreaming(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startNewChat = useCallback(() => {
|
const startNewChat = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setStreaming(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInput("");
|
setInput("");
|
||||||
setAttachedEventId(null);
|
setAttachedEventId(null);
|
||||||
@ -181,7 +226,81 @@ export default function ChatPage() {
|
|||||||
setAttachedEventId(null);
|
setAttachedEventId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hasStarted = messages.length > 0;
|
const hasStarted = messages.length > 0 || streaming != null;
|
||||||
|
|
||||||
|
// The conversation plus any in-flight turn, rendered as one flat list.
|
||||||
|
const renderList = streaming ? [...messages, ...streaming.turn] : messages;
|
||||||
|
const responses = toolResponsesById(renderList);
|
||||||
|
const streamingTail = streaming?.turn[streaming.turn.length - 1];
|
||||||
|
const finalShown =
|
||||||
|
streamingTail?.role === "assistant" && hasText(streamingTail.content);
|
||||||
|
|
||||||
|
const renderMessage = (msg: ChatMessage, i: number) => {
|
||||||
|
if (msg.role === "tool") return null;
|
||||||
|
|
||||||
|
if (msg.role === "user") {
|
||||||
|
if (!hasText(msg.content)) return null;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col gap-2">
|
||||||
|
<MessageBubble
|
||||||
|
role="user"
|
||||||
|
content={msg.content}
|
||||||
|
messageIndex={i}
|
||||||
|
onEditSubmit={handleEditSubmit}
|
||||||
|
isComplete
|
||||||
|
showStats={showStats}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls = toolCallsForMessage(msg, responses);
|
||||||
|
const contentText = hasText(msg.content) ? msg.content : "";
|
||||||
|
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||||
|
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col gap-2">
|
||||||
|
{calls.length > 0 && <ToolCallsGroup toolCalls={calls} />}
|
||||||
|
{hasText(msg.reasoning) && (
|
||||||
|
<ReasoningBubble
|
||||||
|
reasoning={msg.reasoning}
|
||||||
|
answerStarted={!!contentText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{contentText && (
|
||||||
|
<MessageBubble
|
||||||
|
role="assistant"
|
||||||
|
content={contentText}
|
||||||
|
messageIndex={i}
|
||||||
|
isComplete
|
||||||
|
stats={msg.stats}
|
||||||
|
showStats={showStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{similar ? (
|
||||||
|
<ChatEventThumbnailsRow
|
||||||
|
events={similar.results}
|
||||||
|
anchor={similar.anchor}
|
||||||
|
onAttach={setAttachedEventId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChatEventThumbnailsRow
|
||||||
|
events={events}
|
||||||
|
onAttach={setAttachedEventId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processingDots = (
|
||||||
|
<div 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 (
|
return (
|
||||||
<div className="flex size-full flex-col">
|
<div className="flex size-full flex-col">
|
||||||
@ -212,102 +331,31 @@ export default function ChatPage() {
|
|||||||
<div className="flex w-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
<div className="flex w-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
||||||
{hasStarted ? (
|
{hasStarted ? (
|
||||||
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
||||||
{messages.map((msg, i) => {
|
{renderList.map((msg, i) => renderMessage(msg, i))}
|
||||||
const isLastAssistant =
|
{streaming &&
|
||||||
i === messages.length - 1 && msg.role === "assistant";
|
!finalShown &&
|
||||||
const isComplete =
|
(streaming.content || streaming.reasoning ? (
|
||||||
msg.role === "user" || !isLoading || !isLastAssistant;
|
<div className="flex flex-col gap-2">
|
||||||
const hasToolCalls =
|
{hasText(streaming.reasoning) && (
|
||||||
msg.toolCalls && msg.toolCalls.length > 0;
|
|
||||||
const hasContent = !!msg.content?.trim();
|
|
||||||
const hasReasoning = !!msg.reasoning?.trim();
|
|
||||||
const showProcessing =
|
|
||||||
isLastAssistant &&
|
|
||||||
isLoading &&
|
|
||||||
!hasContent &&
|
|
||||||
!hasReasoning;
|
|
||||||
|
|
||||||
// Hide empty placeholder only when there are no tool calls
|
|
||||||
// and no reasoning streaming yet
|
|
||||||
if (
|
|
||||||
isLastAssistant &&
|
|
||||||
isLoading &&
|
|
||||||
!hasContent &&
|
|
||||||
!hasToolCalls &&
|
|
||||||
!hasReasoning
|
|
||||||
)
|
|
||||||
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!} />
|
|
||||||
)}
|
|
||||||
{msg.role === "assistant" && hasReasoning && (
|
|
||||||
<ReasoningBubble
|
<ReasoningBubble
|
||||||
reasoning={msg.reasoning!}
|
reasoning={streaming.reasoning}
|
||||||
answerStarted={hasContent}
|
answerStarted={!!streaming.content}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showProcessing ? (
|
{streaming.content && (
|
||||||
<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
|
<MessageBubble
|
||||||
role={msg.role}
|
role="assistant"
|
||||||
content={msg.content}
|
content={streaming.content}
|
||||||
messageIndex={i}
|
messageIndex={-1}
|
||||||
onEditSubmit={
|
isComplete={false}
|
||||||
msg.role === "user" ? handleEditSubmit : undefined
|
stats={streaming.stats}
|
||||||
}
|
|
||||||
isComplete={isComplete}
|
|
||||||
stats={msg.stats}
|
|
||||||
showStats={showStats}
|
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={events}
|
|
||||||
onAttach={setAttachedEventId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
) : (
|
||||||
})}
|
processingDots
|
||||||
|
))}
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user