diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx new file mode 100644 index 000000000..1c52f2921 --- /dev/null +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -0,0 +1,60 @@ +import { IconCheck, IconCopy } from "@tabler/icons-react" +import { useState } from "react" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" + +import { Button } from "@/components/ui/button" +import { formatMessageTime } from "@/hooks/use-pico-chat" + +interface AssistantMessageProps { + content: string + timestamp?: string | number +} + +export function AssistantMessage({ + content, + timestamp = "", +}: AssistantMessageProps) { + const [isCopied, setIsCopied] = useState(false) + + const handleCopy = () => { + navigator.clipboard.writeText(content).then(() => { + setIsCopied(true) + setTimeout(() => setIsCopied(false), 2000) + }) + } + + return ( +
+
+
+ PicoClaw + {timestamp && ( + <> + + {formatMessageTime(timestamp)} + + )} +
+
+ +
+
+ {content} +
+ +
+
+ ) +} diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx new file mode 100644 index 000000000..ecebcb8cb --- /dev/null +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -0,0 +1,95 @@ +import { IconArrowUp, IconMicrophone, IconPaperclip } from "@tabler/icons-react" +import type { KeyboardEvent } from "react" +import { useTranslation } from "react-i18next" +import TextareaAutosize from "react-textarea-autosize" + +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +interface ChatComposerProps { + input: string + onInputChange: (value: string) => void + onSend: () => void + isConnected: boolean + hasDefaultModel: boolean +} + +export function ChatComposer({ + input, + onInputChange, + onSend, + isConnected, + hasDefaultModel, +}: ChatComposerProps) { + const { t } = useTranslation() + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing) return + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + onSend() + } + } + + return ( +
+
+ onInputChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t("chat.placeholder")} + disabled={!isConnected || !hasDefaultModel} + className="max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent" + minRows={1} + maxRows={8} + /> + +
+
+ + + + + {t("chat.attach")} + + + + + + + {t("chat.voice")} + +
+ + +
+
+
+ ) +} diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx new file mode 100644 index 000000000..624ff9c59 --- /dev/null +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -0,0 +1,87 @@ +import { + IconPlugConnectedX, + IconRobot, + IconRobotOff, + IconStar, +} from "@tabler/icons-react" +import { Link } from "@tanstack/react-router" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" + +interface ChatEmptyStateProps { + hasConfiguredModels: boolean + defaultModelName: string + isConnected: boolean +} + +export function ChatEmptyState({ + hasConfiguredModels, + defaultModelName, + isConnected, +}: ChatEmptyStateProps) { + const { t } = useTranslation() + + if (!hasConfiguredModels) { + return ( +
+
+ +
+

+ {t("chat.empty.noConfiguredModel")} +

+

+ {t("chat.empty.noConfiguredModelDescription")} +

+ +
+ ) + } + + if (!defaultModelName) { + return ( +
+
+ +
+

+ {t("chat.empty.noSelectedModel")} +

+

+ {t("chat.empty.noSelectedModelDescription")} +

+
+ ) + } + + if (!isConnected) { + return ( +
+
+ +
+

+ {t("chat.empty.notRunning")} +

+

+ {t("chat.empty.notRunningDescription")} +

+
+ ) + } + + return ( +
+
+ +
+

{t("chat.welcome")}

+

+ {t("chat.welcomeDesc")} +

+
+ ) +} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx new file mode 100644 index 000000000..418beadb9 --- /dev/null +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -0,0 +1,150 @@ +import { IconPlus } from "@tabler/icons-react" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import { AssistantMessage } from "@/components/chat/assistant-message" +import { ChatComposer } from "@/components/chat/chat-composer" +import { ChatEmptyState } from "@/components/chat/chat-empty-state" +import { ModelSelector } from "@/components/chat/model-selector" +import { SessionHistoryMenu } from "@/components/chat/session-history-menu" +import { TypingIndicator } from "@/components/chat/typing-indicator" +import { UserMessage } from "@/components/chat/user-message" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { useChatModels } from "@/hooks/use-chat-models" +import { useGateway } from "@/hooks/use-gateway" +import { usePicoChat } from "@/hooks/use-pico-chat" +import { useSessionHistory } from "@/hooks/use-session-history" + +export function ChatPage() { + const { t } = useTranslation() + const scrollRef = useRef(null) + const [isAtBottom, setIsAtBottom] = useState(true) + const [input, setInput] = useState("") + + const { + messages, + isTyping, + activeSessionId, + sendMessage, + switchSession, + newChat, + } = usePicoChat() + + const { state: gwState } = useGateway() + const isConnected = gwState === "running" + + const { + defaultModelName, + hasConfiguredModels, + apiKeyModels, + oauthModels, + localModels, + handleSetDefault, + } = useChatModels({ isConnected }) + + const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } = + useSessionHistory({ + activeSessionId, + onDeletedActiveSession: newChat, + }) + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget + setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) + } + + useEffect(() => { + if (isAtBottom && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messages, isTyping, isAtBottom]) + + const handleSend = () => { + if (!input.trim() || !isConnected) return + sendMessage(input.trim()) + setInput("") + } + + return ( +
+ + ) + } + > + + + { + if (open) { + void loadSessions(true) + } + }} + onSwitchSession={switchSession} + onDeleteSession={handleDeleteSession} + /> + + +
+
+ {messages.length === 0 && !isTyping && ( + + )} + + {messages.map((msg) => ( +
+ {msg.role === "assistant" ? ( + + ) : ( + + )} +
+ ))} + + {isTyping && } +
+
+ + +
+ ) +} diff --git a/web/frontend/src/components/chat/model-selector.tsx b/web/frontend/src/components/chat/model-selector.tsx new file mode 100644 index 000000000..6f3e0cafc --- /dev/null +++ b/web/frontend/src/components/chat/model-selector.tsx @@ -0,0 +1,84 @@ +import { useTranslation } from "react-i18next" + +import type { ModelInfo } from "@/api/models" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface ModelSelectorProps { + defaultModelName: string + apiKeyModels: ModelInfo[] + oauthModels: ModelInfo[] + localModels: ModelInfo[] + onValueChange: (modelName: string) => void +} + +export function ModelSelector({ + defaultModelName, + apiKeyModels, + oauthModels, + localModels, + onValueChange, +}: ModelSelectorProps) { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/web/frontend/src/components/chat/session-history-menu.tsx b/web/frontend/src/components/chat/session-history-menu.tsx new file mode 100644 index 000000000..36bb0eedd --- /dev/null +++ b/web/frontend/src/components/chat/session-history-menu.tsx @@ -0,0 +1,98 @@ +import { IconHistory, IconTrash } from "@tabler/icons-react" +import dayjs from "dayjs" +import type { RefObject } from "react" +import { useTranslation } from "react-i18next" + +import type { SessionSummary } from "@/api/sessions" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface SessionHistoryMenuProps { + sessions: SessionSummary[] + activeSessionId: string + hasMore: boolean + observerRef: RefObject + onOpenChange: (open: boolean) => void + onSwitchSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void +} + +export function SessionHistoryMenu({ + sessions, + activeSessionId, + hasMore, + observerRef, + onOpenChange, + onSwitchSession, + onDeleteSession, +}: SessionHistoryMenuProps) { + const { t } = useTranslation() + + return ( + + + + + + + {sessions.length === 0 ? ( + + + {t("chat.noHistory")} + + + ) : ( + sessions.map((session) => ( + onSwitchSession(session.id)} + > + + {session.preview} + + + {t("chat.messagesCount", { + count: session.message_count, + })}{" "} + · {dayjs(session.updated).fromNow()} + + + + )) + )} + {hasMore && sessions.length > 0 && ( +
+ + {t("chat.loadingMore", "Loading more...")} + +
+ )} +
+
+
+ ) +} diff --git a/web/frontend/src/components/chat/typing-indicator.tsx b/web/frontend/src/components/chat/typing-indicator.tsx new file mode 100644 index 000000000..98580963d --- /dev/null +++ b/web/frontend/src/components/chat/typing-indicator.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +export function TypingIndicator() { + const { t } = useTranslation() + const thinkingSteps = [ + t("chat.thinking.step1"), + t("chat.thinking.step2"), + t("chat.thinking.step3"), + t("chat.thinking.step4"), + ] + const [stepIndex, setStepIndex] = useState(0) + + useEffect(() => { + const stepsCount = thinkingSteps.length + const interval = setInterval(() => { + setStepIndex((prev) => (prev + 1) % stepsCount) + }, 3000) + return () => clearInterval(interval) + }, [thinkingSteps.length]) + + return ( +
+
+ PicoClaw +
+
+
+ + + +
+ +
+
+
+ +

+ {thinkingSteps[stepIndex]} +

+
+
+ ) +} diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx new file mode 100644 index 000000000..b47806f49 --- /dev/null +++ b/web/frontend/src/components/chat/user-message.tsx @@ -0,0 +1,13 @@ +interface UserMessageProps { + content: string +} + +export function UserMessage({ content }: UserMessageProps) { + return ( +
+
+ {content} +
+
+ ) +} diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts new file mode 100644 index 000000000..8a82ceaf3 --- /dev/null +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useMemo, useState } from "react" + +import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" + +interface UseChatModelsOptions { + isConnected: boolean +} + +function isLocalModel(model: ModelInfo): boolean { + const isLocalHostBase = Boolean( + model.api_base?.includes("localhost") || + model.api_base?.includes("127.0.0.1"), + ) + + return ( + model.auth_method === "local" || (!model.auth_method && isLocalHostBase) + ) +} + +export function useChatModels({ isConnected }: UseChatModelsOptions) { + const [modelList, setModelList] = useState([]) + const [defaultModelName, setDefaultModelName] = useState("") + + const loadModels = useCallback(async () => { + try { + const data = await getModels() + setModelList(data.models) + if (data.models.some((m) => m.model_name === data.default_model)) { + setDefaultModelName(data.default_model) + } + } catch { + // silently fail + } + }, []) + + useEffect(() => { + const timerId = setTimeout(() => { + void loadModels() + }, 0) + + return () => clearTimeout(timerId) + }, [isConnected, loadModels]) + + const handleSetDefault = useCallback(async (modelName: string) => { + try { + await setDefaultModel(modelName) + setDefaultModelName(modelName) + setModelList((prev) => + prev.map((m) => ({ ...m, is_default: m.model_name === modelName })), + ) + } catch (err) { + console.error("Failed to set default model:", err) + } + }, []) + + const hasConfiguredModels = useMemo( + () => modelList.some((m) => m.configured), + [modelList], + ) + + const oauthModels = useMemo( + () => modelList.filter((m) => m.configured && m.auth_method === "oauth"), + [modelList], + ) + + const localModels = useMemo( + () => modelList.filter((m) => m.configured && isLocalModel(m)), + [modelList], + ) + + const apiKeyModels = useMemo( + () => + modelList.filter( + (m) => m.configured && m.auth_method !== "oauth" && !isLocalModel(m), + ), + [modelList], + ) + + return { + defaultModelName, + hasConfiguredModels, + apiKeyModels, + oauthModels, + localModels, + handleSetDefault, + } +} diff --git a/web/frontend/src/hooks/use-session-history.ts b/web/frontend/src/hooks/use-session-history.ts new file mode 100644 index 000000000..1a6d5c956 --- /dev/null +++ b/web/frontend/src/hooks/use-session-history.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from "react" + +import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions" + +const LIMIT = 20 + +interface UseSessionHistoryOptions { + activeSessionId: string + onDeletedActiveSession: () => void +} + +export function useSessionHistory({ + activeSessionId, + onDeletedActiveSession, +}: UseSessionHistoryOptions) { + const observerRef = useRef(null) + const [sessions, setSessions] = useState([]) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) + + const loadSessions = useCallback( + async (reset = true) => { + try { + const currentOffset = reset ? 0 : offset + if (reset) { + setHasMore(true) + setOffset(0) + } + + const data = await getSessions(currentOffset, LIMIT) + + if (data.length < LIMIT) { + setHasMore(false) + } + + if (reset) { + setSessions(data) + } else { + setSessions((prev) => { + const existingIds = new Set(prev.map((s) => s.id)) + const newItems = data.filter((s) => !existingIds.has(s.id)) + return [...prev, ...newItems] + }) + } + + setOffset(currentOffset + data.length) + } catch { + // silently fail + } finally { + setIsLoadingMore(false) + } + }, + [offset], + ) + + useEffect(() => { + if (!observerRef.current || !hasMore || isLoadingMore) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoadingMore) { + setIsLoadingMore(true) + void loadSessions(false) + } + }, + { threshold: 0.1 }, + ) + + observer.observe(observerRef.current) + return () => observer.disconnect() + }, [hasMore, isLoadingMore, loadSessions]) + + const handleDeleteSession = useCallback( + async (id: string) => { + try { + await deleteSession(id) + setSessions((prev) => prev.filter((s) => s.id !== id)) + if (id === activeSessionId) { + onDeletedActiveSession() + } + } catch (err) { + console.error("Failed to delete session:", err) + } + }, + [activeSessionId, onDeletedActiveSession], + ) + + return { + sessions, + hasMore, + observerRef, + loadSessions, + handleDeleteSession, + } +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index cee3493c8..8b6b02c8d 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -19,7 +19,6 @@ "voice": "Voice input", "newChat": "New Chat", "connecting": "Connecting...", - "connectFirst": "Connect to gateway to start chatting", "notConnected": "Gateway is not running. Start it to chat.", "thinking": { "step1": "Thinking...", @@ -35,11 +34,18 @@ }, "history": "History", "noHistory": "No chat history yet", + "loadingMore": "Loading more...", + "deleteSession": "Delete session", "messagesCount": "{{count}} messages", "noModel": "Select model", - "setupModel": { - "title": "No Model Configured", - "description": "You need to configure at least one AI model with an API key before you can start chatting." + "empty": { + "noConfiguredModel": "No Model Configured", + "noConfiguredModelDescription": "You need to configure at least one AI model with an API key before you can start chatting.", + "goToModels": "Go to Models", + "noSelectedModel": "No Model Selected", + "noSelectedModelDescription": "You have configured models, but none is set as default. Select a model before starting chat.", + "notRunning": "Gateway Not Running", + "notRunningDescription": "Start the gateway service to begin chatting. Use the Start Gateway button in the top bar." }, "modelGroup": { "apikey": "API Key", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index bb1994cb2..69bddd7e6 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -19,7 +19,6 @@ "voice": "语音输入", "newChat": "新建对话", "connecting": "连接中...", - "connectFirst": "请先启动服务以开始对话", "notConnected": "服务未运行,请先启动以进行对话。", "thinking": { "step1": "思考中...", @@ -35,11 +34,18 @@ }, "history": "历史记录", "noHistory": "暂无对话历史", + "loadingMore": "加载更多...", + "deleteSession": "删除会话", "messagesCount": "{{count}} 条消息", "noModel": "选择模型", - "setupModel": { - "title": "尚未配置模型", - "description": "请先配置至少一个带有 API Key 的 AI 模型,才能开始对话。" + "empty": { + "noConfiguredModel": "尚未配置模型", + "noConfiguredModelDescription": "请先配置至少一个带有 API Key 的 AI 模型,才能开始对话。", + "goToModels": "去模型页配置", + "noSelectedModel": "尚未设置模型", + "noSelectedModelDescription": "您已配置模型,但尚未设置默认模型。请选择一个模型后开始对话", + "notRunning": "服务尚未运行", + "notRunningDescription": "请先启动网关服务后再开始对话,可点击顶部栏中的「启动服务」按钮。" }, "modelGroup": { "apikey": "API Key", diff --git a/web/frontend/src/routes/index.tsx b/web/frontend/src/routes/index.tsx index 8252dae43..0006df485 100644 --- a/web/frontend/src/routes/index.tsx +++ b/web/frontend/src/routes/index.tsx @@ -1,591 +1,7 @@ -import { - IconArrowUp, - IconCheck, - IconCopy, - IconHistory, - IconMicrophone, - IconPaperclip, - IconPlugConnectedX, - IconPlus, - IconSparkles, - IconTrash, -} from "@tabler/icons-react" import { createFileRoute } from "@tanstack/react-router" -import dayjs from "dayjs" -import { useCallback, useEffect, useRef, useState } from "react" -import { useTranslation } from "react-i18next" -import ReactMarkdown from "react-markdown" -import TextareaAutosize from "react-textarea-autosize" -import remarkGfm from "remark-gfm" -import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" -import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions" -import { PageHeader } from "@/components/page-header" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { useGateway } from "@/hooks/use-gateway" -import { formatMessageTime, usePicoChat } from "@/hooks/use-pico-chat" - -// Assistant Message Component -function AssistantMessage({ - content, - timestamp = "", -}: { - content: string - timestamp?: string | number -}) { - const [isCopied, setIsCopied] = useState(false) - - const handleCopy = () => { - navigator.clipboard.writeText(content).then(() => { - setIsCopied(true) - setTimeout(() => setIsCopied(false), 2000) - }) - } - - return ( -
-
-
- PicoClaw - {timestamp && ( - <> - - {formatMessageTime(timestamp)} - - )} -
-
- -
-
- {content} -
- -
-
- ) -} - -// User Message Component -function UserMessage({ content }: { content: string }) { - return ( -
-
- {content} -
-
- ) -} - -function TypingIndicator() { - const { t } = useTranslation() - const thinkingSteps = [ - t("chat.thinking.step1"), - t("chat.thinking.step2"), - t("chat.thinking.step3"), - t("chat.thinking.step4"), - ] - const [stepIndex, setStepIndex] = useState(0) - - useEffect(() => { - const stepsCount = thinkingSteps.length - const interval = setInterval(() => { - setStepIndex((prev) => (prev + 1) % stepsCount) - }, 3000) - return () => clearInterval(interval) - }, [thinkingSteps.length]) - - return ( -
-
- PicoClaw -
-
- {/* Bouncing dots */} -
- - - -
- - {/* Shimmer progress bar */} -
-
-
- - {/* Rotating status text */} -

- {thinkingSteps[stepIndex]} -

-
-
- ) -} +import { ChatPage } from "@/components/chat/chat-page" export const Route = createFileRoute("/")({ - component: Index, + component: ChatPage, }) - -const LIMIT = 20 - -function Index() { - const { t } = useTranslation() - const scrollRef = useRef(null) - const observerRef = useRef(null) - const [isAtBottom, setIsAtBottom] = useState(true) - const [input, setInput] = useState("") - const [sessions, setSessions] = useState([]) - const [offset, setOffset] = useState(0) - const [hasMore, setHasMore] = useState(true) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const [modelList, setModelList] = useState([]) - const [defaultModelName, setDefaultModelName] = useState("") - - const { - messages, - isTyping, - activeSessionId, - sendMessage, - switchSession, - newChat, - } = usePicoChat() - - const { state: gwState } = useGateway() - const isConnected = gwState === "running" - const hasConfiguredModels = modelList.some((m) => m.configured) - - const oauthModels = modelList.filter( - (m) => m.configured && m.auth_method === "oauth", - ) - const localModels = modelList.filter( - (m) => - m.configured && - (m.auth_method === "local" || - (!m.auth_method && - (m.api_base?.includes("localhost") || - m.api_base?.includes("127.0.0.1")))), - ) - const apiKeyModels = modelList.filter( - (m) => m.configured && !oauthModels.includes(m) && !localModels.includes(m), - ) - - // Load models list - const loadModels = useCallback(async () => { - try { - const data = await getModels() - setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } - } catch { - // silently fail - } - }, []) - - // Fetch models on mount and when gateway connects - useEffect(() => { - loadModels() - }, [isConnected, loadModels]) - - const handleSetDefault = async (modelName: string) => { - try { - await setDefaultModel(modelName) - setDefaultModelName(modelName) - setModelList((prev) => - prev.map((m) => ({ ...m, is_default: m.model_name === modelName })), - ) - } catch (err) { - console.error("Failed to set default model:", err) - } - } - - const loadSessions = useCallback( - async (reset = true) => { - try { - const currentOffset = reset ? 0 : offset - if (reset) { - setHasMore(true) - setOffset(0) - } - - const data = await getSessions(currentOffset, LIMIT) - - if (data.length < LIMIT) { - setHasMore(false) - } - - if (reset) { - setSessions(data) - } else { - setSessions((prev) => { - // Filter out duplicates just in case - const existingIds = new Set(prev.map((s) => s.id)) - const newItems = data.filter((s) => !existingIds.has(s.id)) - return [...prev, ...newItems] - }) - } - - setOffset(currentOffset + data.length) - } catch { - // silently fail - } finally { - setIsLoadingMore(false) - } - }, - [offset], - ) - - // Intersection Observer for infinite scrolling - useEffect(() => { - if (!observerRef.current || !hasMore || isLoadingMore) return - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && hasMore && !isLoadingMore) { - setIsLoadingMore(true) - loadSessions(false) - } - }, - { threshold: 0.1 }, - ) - - observer.observe(observerRef.current) - - return () => observer.disconnect() - }, [hasMore, isLoadingMore, loadSessions]) - - const handleDeleteSession = async (id: string) => { - try { - await deleteSession(id) - setSessions((prev) => prev.filter((s) => s.id !== id)) - if (id === activeSessionId) { - newChat() - } - } catch (err) { - console.error("Failed to delete session:", err) - } - } - - // Track if user has naturally scrolled away from the bottom - const handleScroll = (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget - setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) - } - - // Auto-scroll to bottom when new messages arrive (if already at bottom) - useEffect(() => { - if (isAtBottom && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight - } - }, [messages, isTyping, isAtBottom]) - - const handleSend = () => { - if (!input.trim() || !isConnected) return - sendMessage(input.trim()) - setInput("") - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.nativeEvent.isComposing) return - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - return ( -
- - - - - - {apiKeyModels.length > 0 && ( - - - {t("chat.modelGroup.apikey", "API Key")} - - {apiKeyModels.map((model) => ( - - {model.model_name} - - ))} - - )} - {apiKeyModels.length > 0 && - (oauthModels.length > 0 || localModels.length > 0) && ( - - )} - - {oauthModels.length > 0 && ( - - - {t("chat.modelGroup.oauth", "OAuth")} - - {oauthModels.map((model) => ( - - {model.model_name} - - ))} - - )} - {oauthModels.length > 0 && - (localModels.length > 0 || apiKeyModels.length > 0) && ( - - )} - - {localModels.length > 0 && ( - - - {t("chat.modelGroup.local", "Local")} - - {localModels.map((model) => ( - - {model.model_name} - - ))} - - )} - - - } - > - - - { - if (open) { - loadSessions(true) - } - }} - > - - - - - - {sessions.length === 0 ? ( - - - {t("chat.noHistory")} - - - ) : ( - sessions.map((session) => ( - switchSession(session.id)} - > - - {session.preview} - - - {t("chat.messagesCount", { - count: session.message_count, - })}{" "} - · {dayjs(session.updated).fromNow()} - - - - )) - )} - {hasMore && sessions.length > 0 && ( -
- - Loading more... - -
- )} -
-
-
-
- {/* Chat Messages Area */} -
-
- {messages.length === 0 && !isTyping && ( -
- {!hasConfiguredModels || !defaultModelName ? ( - <> -
- -
-

- {t("chat.setupModel.title")} -

-

- {t("chat.setupModel.description")} -

- - ) : !isConnected ? ( - <> -
- -
-

- {t("chat.connectFirst")} -

- - ) : ( - <> -
- -
-

- {t("chat.welcome")} -

-

- {t("chat.welcomeDesc")} -

- - )} -
- )} - - {messages.map((msg) => ( -
- {msg.role === "assistant" ? ( - - ) : ( - - )} -
- ))} - - {isTyping && } -
-
- - {/* Input Area */} -
-
- setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={t("chat.placeholder")} - disabled={!isConnected || !defaultModelName} - className="max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent" - minRows={1} - maxRows={8} - /> - -
-
- - - - - {t("chat.attach")} - - - - - - - {t("chat.voice")} - -
- - -
-
-
-
- ) -}