diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 81df8bb..2a20c72 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -44,7 +44,7 @@ export default async function RootLayout(props: { - {props.children} + {props.children} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 523ded9..b7f2185 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,17 +1,85 @@ +'use client'; + +import { useSession } from 'next-auth/react'; import { useTranslations } from 'next-intl'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import ChatUI from '@/components/chat/ChatUI'; +import { ModelInfo } from '@/types/chat-api'; +import { signIn } from 'next-auth/react'; + +// --------------------------------------------------------------------------- +// localStorage store for preferred model. +// getServerSnapshot always returns null → SSR and first client render agree. +// After hydration React switches to getSnapshot and reads the real value. +// --------------------------------------------------------------------------- +const STORAGE_KEY = 'preferred_model'; + +function subscribeStorage(cb: () => void) { + window.addEventListener('storage', cb); + return () => window.removeEventListener('storage', cb); +} +const getStorageSnapshot = () => localStorage.getItem(STORAGE_KEY); +const getStorageServerSnapshot = (): null => null; + +// --------------------------------------------------------------------------- export default function Home() { + const { data: session, status } = useSession(); const t = useTranslations('home'); + const isLoggedIn = status === 'authenticated'; + + const [models, setModels] = useState([]); + + const savedModel = useSyncExternalStore( + subscribeStorage, + getStorageSnapshot, + getStorageServerSnapshot, + ); + + const [overrideModel, setOverrideModel] = useState(null); + + const selectedModel = overrideModel ?? savedModel ?? models[0]?.name ?? null; + + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + if (overrideModel) { + localStorage.setItem(STORAGE_KEY, overrideModel); + } + }, [overrideModel]); + + useEffect(() => { + if (!session?.accessToken) return; + fetch(process.env.NEXT_PUBLIC_API_URL + '/models', { + headers: { Authorization: `Bearer ${session.accessToken}` }, + }) + .then((res) => res.json()) + .then((result: { models?: ModelInfo[] }) => { + setModels(result?.models ?? []); + }); + }, [session]); + + if (!isLoggedIn) { + return ( + + {t('name')} + {t('welcome')} + signIn('keycloak')}> + {t('start')} + + + ); + } return ( - - {t('name')} - - {t('welcome')} - - - {t('start')} - - + ); } diff --git a/src/components/chat/ChatUI.tsx b/src/components/chat/ChatUI.tsx new file mode 100644 index 0000000..8567785 --- /dev/null +++ b/src/components/chat/ChatUI.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState } from 'react'; +import ModelSelect from './ModelSelect'; +import Sidebar from '../Sidebar'; +import { SIDEBAR_PANELS } from './chat.panels'; +import { Session } from 'next-auth'; +import { Message, ModelInfo } from '@/types/chat-api'; + +type Props = { + session: Session; + models: ModelInfo[]; + selectedModel: string | null; + setSelectedModel: (v: string) => void; +}; + +export default function ChatUI({ + session, + models, + selectedModel, + setSelectedModel, +}: Props) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + + const sendMessage = async () => { + if (!input.trim() || !selectedModel) return; + const userMessage = { role: 'user' as const, content: input }; + const newMessages = [...messages, userMessage]; + setMessages(newMessages); + setInput(''); + setLoading(true); + + try { + const res = await fetch( + process.env.NEXT_PUBLIC_API_URL + '/chat/completions', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session?.accessToken}`, + }, + body: JSON.stringify({ model: selectedModel, messages: newMessages }), + }, + ); + const data = await res.json(); + const assistant = data?.choices?.[0]?.message; + if (assistant) setMessages((prev) => [...prev, assistant]); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + return ( + + {/* ── Sidebar (icon rail + sliding panel) ── */} + + + {/* ── Main chat area ── */} + + {/* Top bar */} + + + + + {/* Messages */} + + + {messages.map((msg, i) => ( + + {msg.role === 'user' ? 'You' : 'AI'}: {msg.content} + + ))} + {loading && Thinking...} + + + + {/* Input */} + + + setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && sendMessage()} + className="w-full p-3 rounded-xl bg-black/20 border border-muted outline-none" + placeholder="Message..." + /> + + + + + ); +} diff --git a/src/components/chat/ModelSelect.tsx b/src/components/chat/ModelSelect.tsx new file mode 100644 index 0000000..151441d --- /dev/null +++ b/src/components/chat/ModelSelect.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +type Model = { + name: string; +}; + +type Props = { + models: Model[]; + selectedModel: string | null; + setSelectedModel: (v: string) => void; +}; + +export default function ModelSelect({ + models, + selectedModel, + setSelectedModel, +}: Props) { + const [open, setOpen] = useState(false); + + const maxWidth = useMemo(() => { + const longest = + models?.reduce((a, b) => (a.length > b.name.length ? a : b.name), '') ?? + ''; + return Math.min(Math.max(longest.length * 8, 140), 300); + }, [models]); + + return ( + + {/* Trigger */} + setOpen((v) => !v)} + className="flex items-center gap-2 bg-secondary border border-muted rounded-lg px-3 py-2 text-sm text-primary transition-fast" + > + {selectedModel || 'Select model'} + + {/* Chevron */} + + + + + + {/* Dropdown */} + + {models?.map((model) => ( + { + setSelectedModel(model.name); + setOpen(false); + }} + className="px-3 py-2 text-sm hover:bg-black/20 cursor-pointer whitespace-nowrap" + > + {model.name} + + ))} + + + ); +} diff --git a/src/components/chat/chat.panels.tsx b/src/components/chat/chat.panels.tsx new file mode 100644 index 0000000..bb6b1e4 --- /dev/null +++ b/src/components/chat/chat.panels.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { SidebarPanel } from '../Sidebar'; + +// ─── Example panels ─────────────────────────────────────────────────────────── + +function ConversationHistoryPanel() { + const history = [ + { id: 1, title: 'What is TypeScript?', time: '2m ago' }, + { id: 2, title: 'Fix my React hook bug', time: '1h ago' }, + { id: 3, title: 'Explain REST vs GraphQL', time: 'Yesterday' }, + ]; + return ( + + {history.map((h) => ( + + {h.title} + {h.time} + + ))} + + ); +} + +function SettingsPanel() { + const [temp, setTemp] = useState(0.7); + const [stream, setStream] = useState(true); + + return ( + + + + Temperature + + + setTemp(Number(e.target.value))} + className="flex-1 accent-violet-400" + /> + {temp} + + + + Streaming + setStream((s) => !s)} + className={`w-10 h-5 rounded-full transition-colors ${ + stream ? 'bg-violet-500' : 'bg-white/20' + } relative`} + > + + + + + ); +} + +function InfoPanel() { + return ( + + + + Tokens used + 1,240 + + + Context left + 126k + + + Messages + 8 + + + Session started 14 min ago + + ); +} + +// ─── Panel registry — add yours here ───────────────────────────────────────── + +export const SIDEBAR_PANELS: SidebarPanel[] = [ + { + id: 'history', + label: 'History', + icon: ( + + + + ), + component: , + }, + { + id: 'settings', + label: 'Settings', + icon: ( + + + + ), + component: , + }, + { + id: 'info', + label: 'Info', + icon: ( + + + + ), + component: , + }, +]; diff --git a/src/types/chat-api.tsx b/src/types/chat-api.tsx new file mode 100644 index 0000000..b38e5a6 --- /dev/null +++ b/src/types/chat-api.tsx @@ -0,0 +1,17 @@ +export type ModelInfo = { + name: string; + family?: string | null; + parameter_size?: string | null; + quantization?: string | null; +}; + +export type ModelsResponse = { + models: ModelInfo[]; +}; + +export type Role = 'system' | 'user' | 'assistant'; + +export type Message = { + role: Role; + content: string; +};
{t('welcome')}
Session started 14 min ago