feat: add chat page
This commit is contained in:
@@ -44,7 +44,7 @@ export default async function RootLayout(props: {
|
|||||||
<Providers locale={locale} messages={messages} session={session}>
|
<Providers locale={locale} messages={messages} session={session}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="flex-1 pt-20 flex flex-col">{props.children}</main>
|
<main className="flex-1 pt-[var(--header-height)] flex flex-col">{props.children}</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|||||||
@@ -1,17 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
import { useTranslations } from 'next-intl';
|
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() {
|
export default function Home() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
const t = useTranslations('home');
|
const t = useTranslations('home');
|
||||||
|
const isLoggedIn = status === 'authenticated';
|
||||||
|
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
|
||||||
|
const savedModel = useSyncExternalStore(
|
||||||
|
subscribeStorage,
|
||||||
|
getStorageSnapshot,
|
||||||
|
getStorageServerSnapshot,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [overrideModel, setOverrideModel] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1>
|
||||||
|
<p className="mt-4 text-gray-400">{t('welcome')}</p>
|
||||||
|
<button className="mt-6 px-6 py-3 bg-cyan-500 rounded-xl" onClick={() => signIn('keycloak')}>
|
||||||
|
{t('start')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center">
|
<ChatUI
|
||||||
<h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1>
|
session={session}
|
||||||
|
models={models}
|
||||||
<p className="mt-4 text-gray-400">{t('welcome')}</p>
|
selectedModel={selectedModel}
|
||||||
|
setSelectedModel={setOverrideModel}
|
||||||
<button className="mt-6 px-6 py-3 bg-cyan-500 hover:bg-cyan-600 rounded-xl">
|
/>
|
||||||
{t('start')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Message[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-1 bg-primary text-primary transition-theme">
|
||||||
|
{/* ── Sidebar (icon rail + sliding panel) ── */}
|
||||||
|
<Sidebar panels={SIDEBAR_PANELS} />
|
||||||
|
|
||||||
|
{/* ── Main chat area ── */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="p-4 flex justify-between items-center border-b border-white/10">
|
||||||
|
<ModelSelect
|
||||||
|
models={models}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
setSelectedModel={setSelectedModel}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<main className="flex-1 overflow-y-auto flex flex-col justify-end p-6">
|
||||||
|
<div className="w-full max-w-2xl mx-auto space-y-3">
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} className="text-sm">
|
||||||
|
<b>{msg.role === 'user' ? 'You' : 'AI'}:</b> {msg.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && <div className="text-xs text-muted">Thinking...</div>}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => 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..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
{/* Trigger */}
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<span>{selectedModel || 'Select model'}</span>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform duration-200 ease-in-out ${
|
||||||
|
open ? 'rotate-180' : 'rotate-0'
|
||||||
|
}`}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M6 8l4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
<div
|
||||||
|
style={{ width: maxWidth }}
|
||||||
|
className={`
|
||||||
|
absolute mt-2 bg-secondary border border-muted rounded-lg
|
||||||
|
overflow-hidden origin-top
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
${
|
||||||
|
open
|
||||||
|
? 'opacity-100 scale-100 translate-y-0'
|
||||||
|
: 'opacity-0 scale-95 -translate-y-1 pointer-events-none'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{models?.map((model) => (
|
||||||
|
<div
|
||||||
|
key={model.name}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedModel(model.name);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 text-sm hover:bg-black/20 cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{history.map((h) => (
|
||||||
|
<li
|
||||||
|
key={h.id}
|
||||||
|
className="flex flex-col px-3 py-2 rounded-lg hover:bg-white/10 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium truncate">{h.title}</span>
|
||||||
|
<span className="text-xs text-white/40">{h.time}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsPanel() {
|
||||||
|
const [temp, setTemp] = useState(0.7);
|
||||||
|
const [stream, setStream] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 px-1">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-primary uppercase tracking-widest transition-theme">
|
||||||
|
Temperature
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
value={temp}
|
||||||
|
onChange={(e) => setTemp(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-violet-400"
|
||||||
|
/>
|
||||||
|
<span className="text-sm w-8 text-right">{temp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Streaming</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setStream((s) => !s)}
|
||||||
|
className={`w-10 h-5 rounded-full transition-colors ${
|
||||||
|
stream ? 'bg-violet-500' : 'bg-white/20'
|
||||||
|
} relative`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
|
||||||
|
stream ? 'left-5' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoPanel() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 px-1 text-sm text-white/70">
|
||||||
|
<div className="rounded-lg bg-white/5 p-3 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Tokens used</span>
|
||||||
|
<span className="text-white">1,240</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Context left</span>
|
||||||
|
<span className="text-white">126k</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Messages</span>
|
||||||
|
<span className="text-white">8</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/30">Session started 14 min ago</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Panel registry — add yours here ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SIDEBAR_PANELS: SidebarPanel[] = [
|
||||||
|
{
|
||||||
|
id: 'history',
|
||||||
|
label: 'History',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .414.336.75.75.75h4a.75.75 0 000-1.5h-3.25V5z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
component: <ConversationHistoryPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Settings',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M7.84 1.804A1 1 0 018.82 1h2.36a1 1 0 01.98.804l.331 1.652a6.993 6.993 0 011.929 1.115l1.598-.54a1 1 0 011.186.447l1.18 2.044a1 1 0 01-.205 1.251l-1.267 1.113a7.047 7.047 0 010 2.228l1.267 1.113a1 1 0 01.205 1.251l-1.18 2.044a1 1 0 01-1.186.447l-1.598-.54a6.993 6.993 0 01-1.929 1.115l-.33 1.652a1 1 0 01-.98.804H8.82a1 1 0 01-.98-.804l-.331-1.652a6.993 6.993 0 01-1.929-1.115l-1.598.54a1 1 0 01-1.186-.447l-1.18-2.044a1 1 0 01.205-1.251l1.267-1.114a7.05 7.05 0 010-2.227L1.821 7.773a1 1 0 01-.206-1.25l1.18-2.045a1 1 0 011.187-.447l1.598.54A6.993 6.993 0 017.51 3.456l.33-1.652zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
component: <SettingsPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'info',
|
||||||
|
label: 'Info',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
component: <InfoPanel />,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user