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 (
+
+ )
+}
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 (
-
- )
-}
-
-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")}
-
-
-
-
-
-
-
-
- )
-}