Add a starting state for chat

This commit is contained in:
Nicolas Mowen 2026-02-26 08:36:52 -07:00
parent 3bac4b15ae
commit f400e91ede
4 changed files with 199 additions and 80 deletions

View File

@ -1,4 +1,6 @@
{ {
"title": "Frigate Chat",
"subtitle": "Your AI assistant for camera management and insights",
"placeholder": "Ask anything...", "placeholder": "Ask anything...",
"error": "Something went wrong. Please try again.", "error": "Something went wrong. Please try again.",
"processing": "Processing...", "processing": "Processing...",
@ -9,5 +11,14 @@
"result": "Result", "result": "Result",
"arguments": "Arguments:", "arguments": "Arguments:",
"response": "Response:", "response": "Response:",
"send": "Send" "send": "Send",
"suggested_requests": "Try asking:",
"starting_requests": {
"show_recent_events": "Show recent events",
"show_camera_status": "Show camera status"
},
"starting_requests_prompts": {
"show_recent_events": "Show me the recent events from the last hour",
"show_camera_status": "What is the current status of my cameras?"
}
} }

View File

@ -0,0 +1,89 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FaArrowUpLong } from "react-icons/fa6";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { StartingRequest } from "@/types/chat";
type ChatStartingStateProps = {
onSendMessage: (message: string) => void;
};
export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
const { t } = useTranslation(["views/chat"]);
const [input, setInput] = useState("");
const defaultRequests: StartingRequest[] = [
{
label: t("starting_requests.show_recent_events"),
prompt: t("starting_requests_prompts.show_recent_events"),
},
{
label: t("starting_requests.show_camera_status"),
prompt: t("starting_requests_prompts.show_camera_status"),
},
];
const handleRequestClick = (prompt: string) => {
onSendMessage(prompt);
};
const handleSubmit = () => {
const text = input.trim();
if (!text) return;
onSendMessage(text);
setInput("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="flex size-full flex-col items-center justify-center gap-6 p-8">
<div className="flex flex-col items-center gap-2">
<h1 className="text-4xl font-bold text-foreground">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="flex w-full max-w-2xl flex-col items-center gap-4">
<p className="text-center text-sm text-muted-foreground">
{t("suggested_requests")}
</p>
<div className="flex w-full flex-wrap justify-center gap-2">
{defaultRequests.map((request, idx) => (
<Button
key={idx}
variant="outline"
className="max-w-sm text-sm"
onClick={() => handleRequestClick(request.prompt)}
>
{request.label}
</Button>
))}
</div>
</div>
<div className="flex w-full max-w-2xl flex-row items-center gap-2 rounded-xl bg-secondary p-4">
<Input
className="h-12 w-full flex-1 border-transparent bg-transparent text-base shadow-none focus-visible:ring-0 dark:bg-transparent"
placeholder={t("placeholder")}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button
variant="select"
className="size-10 shrink-0 rounded-full"
disabled={!input.trim()}
onClick={handleSubmit}
>
<FaArrowUpLong size="18" />
</Button>
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ import axios from "axios";
import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow"; import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow";
import { MessageBubble } from "@/components/chat/ChatMessage"; import { MessageBubble } from "@/components/chat/ChatMessage";
import { ToolCallBubble } from "@/components/chat/ToolCallBubble"; import { ToolCallBubble } from "@/components/chat/ToolCallBubble";
import { ChatStartingState } from "@/components/chat/ChatStartingState";
import type { ChatMessage } from "@/types/chat"; import type { ChatMessage } from "@/types/chat";
import { import {
getEventIdsFromSearchObjectsToolCalls, getEventIdsFromSearchObjectsToolCalls,
@ -78,88 +79,101 @@ export default function ChatPage() {
return ( return (
<div className="flex size-full justify-center p-2"> <div className="flex size-full justify-center p-2">
<div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]"> <div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]">
<div className="scrollbar-container flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto"> {messages.length === 0 ? (
{messages.map((msg, i) => { <ChatStartingState
const isStreamingPlaceholder = onSendMessage={(message) => {
i === messages.length - 1 && setInput("");
msg.role === "assistant" && submitConversation([{ role: "user", content: message }]);
isLoading && }}
!msg.content?.trim() && />
!(msg.toolCalls && msg.toolCalls.length > 0); ) : (
if (isStreamingPlaceholder) { <div className="scrollbar-container flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto">
return <div key={i} />; {messages.map((msg, i) => {
} const isStreamingPlaceholder =
return ( i === messages.length - 1 &&
<div key={i} className="flex flex-col gap-2"> msg.role === "assistant" &&
{msg.role === "assistant" && msg.toolCalls && ( isLoading &&
<> !msg.content?.trim() &&
{msg.toolCalls.map((tc, tcIdx) => ( !(msg.toolCalls && msg.toolCalls.length > 0);
<div key={tcIdx} className="flex flex-col gap-2"> if (isStreamingPlaceholder) {
<ToolCallBubble return <div key={i} />;
name={tc.name} }
arguments={tc.arguments} return (
side="left" <div key={i} className="flex flex-col gap-2">
/> {msg.role === "assistant" && msg.toolCalls && (
{tc.response && ( <>
{msg.toolCalls.map((tc, tcIdx) => (
<div key={tcIdx} className="flex flex-col gap-2">
<ToolCallBubble <ToolCallBubble
name={tc.name} name={tc.name}
response={tc.response} arguments={tc.arguments}
side="right" side="left"
/> />
)} {tc.response && (
</div> <ToolCallBubble
))} name={tc.name}
</> response={tc.response}
)} side="right"
<MessageBubble />
role={msg.role} )}
content={msg.content} </div>
messageIndex={i} ))}
onEditSubmit={ </>
msg.role === "user" ? handleEditSubmit : undefined )}
} <MessageBubble
isComplete={ role={msg.role}
msg.role === "user" || !isLoading || i < messages.length - 1 content={msg.content}
} messageIndex={i}
/> onEditSubmit={
{msg.role === "assistant" && msg.role === "user" ? handleEditSubmit : undefined
(() => { }
const isComplete = !isLoading || i < messages.length - 1; isComplete={
if (!isComplete) return null; msg.role === "user" ||
const events = getEventIdsFromSearchObjectsToolCalls( !isLoading ||
msg.toolCalls, i < messages.length - 1
); }
return <ChatEventThumbnailsRow events={events} />; />
})()} {msg.role === "assistant" &&
</div> (() => {
); const isComplete = !isLoading || i < messages.length - 1;
})} if (!isComplete) return null;
{(() => { const events = getEventIdsFromSearchObjectsToolCalls(
const lastMsg = messages[messages.length - 1]; msg.toolCalls,
const showProcessing = );
isLoading && return <ChatEventThumbnailsRow events={events} />;
lastMsg?.role === "assistant" && })()}
!lastMsg.content?.trim() && </div>
!(lastMsg.toolCalls && lastMsg.toolCalls.length > 0); );
return showProcessing ? ( })}
<div className="self-start rounded-lg bg-muted px-3 py-2 text-muted-foreground"> {(() => {
{t("processing")} const lastMsg = messages[messages.length - 1];
</div> const showProcessing =
) : null; isLoading &&
})()} lastMsg?.role === "assistant" &&
{error && ( !lastMsg.content?.trim() &&
<p className="self-start text-sm text-destructive" role="alert"> !(lastMsg.toolCalls && lastMsg.toolCalls.length > 0);
{error} return showProcessing ? (
</p> <div className="self-start rounded-lg bg-muted px-3 py-2 text-muted-foreground">
)} {t("processing")}
</div> </div>
<ChatEntry ) : null;
input={input} })()}
setInput={setInput} {error && (
sendMessage={sendMessage} <p className="self-start text-sm text-destructive" role="alert">
isLoading={isLoading} {error}
placeholder={t("placeholder")} </p>
/> )}
</div>
)}
{messages.length > 0 && (
<ChatEntry
input={input}
setInput={setInput}
sendMessage={sendMessage}
isLoading={isLoading}
placeholder={t("placeholder")}
/>
)}
</div> </div>
</div> </div>
); );

View File

@ -9,3 +9,8 @@ export type ChatMessage = {
content: string; content: string;
toolCalls?: ToolCall[]; toolCalls?: ToolCall[];
}; };
export type StartingRequest = {
label: string;
prompt: string;
};