frigate/web/src/pages/Chat.tsx

181 lines
5.7 KiB
TypeScript
Raw Normal View History

2026-02-13 03:57:38 +03:00
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FaArrowUpLong } from "react-icons/fa6";
2026-02-13 05:01:22 +03:00
import { useTranslation } from "react-i18next";
import { useState, useCallback } from "react";
import axios from "axios";
2026-02-17 02:57:42 +03:00
import { MessageBubble } from "@/components/chat/ChatMessage";
import { ToolCallBubble } from "@/components/chat/ToolCallBubble";
2026-02-18 04:48:46 +03:00
import type { ChatMessage } from "@/types/chat";
import { streamChatCompletion } from "@/utils/chatUtil";
2026-02-13 03:57:38 +03:00
export default function ChatPage() {
2026-02-13 05:01:22 +03:00
const { t } = useTranslation(["views/chat"]);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
2026-02-18 04:48:46 +03:00
const submitConversation = useCallback(
async (messagesToSend: ChatMessage[]) => {
if (isLoading) return;
const last = messagesToSend[messagesToSend.length - 1];
if (!last || last.role !== "user" || !last.content.trim()) return;
2026-02-13 05:01:22 +03:00
2026-02-18 04:48:46 +03:00
setError(null);
const assistantPlaceholder: ChatMessage = {
role: "assistant",
content: "",
toolCalls: undefined,
};
setMessages([...messagesToSend, assistantPlaceholder]);
setIsLoading(true);
2026-02-13 05:01:22 +03:00
2026-02-18 04:48:46 +03:00
const apiMessages = messagesToSend.map((m) => ({
role: m.role,
content: m.content,
}));
2026-02-17 03:14:17 +03:00
const baseURL = axios.defaults.baseURL ?? "";
const url = `${baseURL}chat/completion`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(axios.defaults.headers.common as Record<string, string>),
};
2026-02-18 04:48:46 +03:00
await streamChatCompletion(url, headers, apiMessages, {
updateMessages: (updater) => setMessages(updater),
onError: (message) => setError(message),
onDone: () => setIsLoading(false),
defaultErrorMessage: t("error"),
});
},
[isLoading, t],
);
2026-02-17 03:14:17 +03:00
2026-02-18 04:48:46 +03:00
const sendMessage = useCallback(() => {
const text = input.trim();
if (!text || isLoading) return;
setInput("");
submitConversation([...messages, { role: "user", content: text }]);
}, [input, isLoading, messages, submitConversation]);
2026-02-13 05:01:22 +03:00
2026-02-18 04:48:46 +03:00
const handleEditSubmit = useCallback(
(messageIndex: number, newContent: string) => {
const newList: ChatMessage[] = [
...messages.slice(0, messageIndex),
{ role: "user", content: newContent },
];
submitConversation(newList);
},
[messages, submitConversation],
);
2026-02-13 05:01:22 +03:00
2026-02-13 03:57:38 +03:00
return (
2026-02-17 02:57:42 +03:00
<div className="flex size-full justify-center p-2">
<div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]">
2026-02-17 05:28:33 +03:00
<div className="scrollbar-container flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto">
2026-02-17 02:57:42 +03:00
{messages.map((msg, i) => (
<div key={i} className="flex flex-col gap-2">
{msg.role === "assistant" && msg.toolCalls && (
<>
{msg.toolCalls.map((tc, tcIdx) => (
<div key={tcIdx} className="flex flex-col gap-2">
<ToolCallBubble
name={tc.name}
2026-02-17 02:57:42 +03:00
arguments={tc.arguments}
side="left"
/>
2026-02-17 02:57:42 +03:00
{tc.response && (
<ToolCallBubble
name={tc.name}
response={tc.response}
side="right"
/>
)}
</div>
))}
</>
)}
2026-02-18 04:48:46 +03:00
<MessageBubble
role={msg.role}
content={msg.content}
messageIndex={i}
onEditSubmit={
msg.role === "user" ? handleEditSubmit : undefined
}
2026-02-18 06:18:50 +03:00
isComplete={
msg.role === "user" || !isLoading || i < messages.length - 1
}
2026-02-18 04:48:46 +03:00
/>
</div>
2026-02-17 02:57:42 +03:00
))}
{isLoading && (
<div className="self-start rounded-lg bg-muted px-3 py-2 text-muted-foreground">
{t("processing")}
</div>
)}
{error && (
<p className="self-start text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
<ChatEntry
input={input}
setInput={setInput}
sendMessage={sendMessage}
isLoading={isLoading}
placeholder={t("placeholder")}
/>
2026-02-13 05:01:22 +03:00
</div>
2026-02-13 03:57:38 +03:00
</div>
);
}
2026-02-13 05:01:22 +03:00
type ChatEntryProps = {
input: string;
setInput: (value: string) => void;
sendMessage: () => void;
isLoading: boolean;
placeholder: string;
};
function ChatEntry({
input,
setInput,
sendMessage,
isLoading,
placeholder,
}: ChatEntryProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
2026-02-13 03:57:38 +03:00
return (
2026-02-17 02:57:42 +03:00
<div className="flex w-full flex-col items-center justify-center rounded-xl bg-secondary p-2">
2026-02-13 05:01:22 +03:00
<div className="flex w-full flex-row items-center gap-2">
<Input
className="w-full flex-1 border-transparent bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent"
placeholder={placeholder}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
<Button
variant="select"
className="size-10 shrink-0 rounded-full"
disabled={!input.trim() || isLoading}
onClick={sendMessage}
>
2026-02-13 03:57:38 +03:00
<FaArrowUpLong size="16" />
</Button>
</div>
</div>
);
}