Compare commits
9
Commits
45b64991eb
...
c4ebb599b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4ebb599b6 | ||
|
|
e2266b7bd7 | ||
|
|
1c7d22e04d | ||
|
|
cae66b50e1 | ||
|
|
fdbbdc2f22 | ||
|
|
4f20ff33ae | ||
|
|
92ef6fa5a9 | ||
|
|
96954a4d7f | ||
|
|
92305c1a12 |
@@ -141,5 +141,4 @@ git tag -d v1.0.0; git push origin :refs/tags/v1.0.0; git tag -a v1.0.0 -m "Rele
|
||||
|
||||
## To implement
|
||||
|
||||
- Better Tailwind CSS classes
|
||||
- See keycloak user infos
|
||||
- Token refresh
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
// return (
|
||||
// <div className="p-6">
|
||||
// <h1>About</h1>
|
||||
|
||||
// {loading && <p>Loading...</p>}
|
||||
|
||||
@@ -31,3 +30,214 @@
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
|
||||
// 'use client';
|
||||
|
||||
// import { useEffect, useState } from 'react';
|
||||
// import Spinner from '@/components/spinner';
|
||||
|
||||
// export default function AboutPage() {
|
||||
// const [data, setData] = useState<any>(null);
|
||||
// const [loading, setLoading] = useState(true);
|
||||
|
||||
// useEffect(() => {
|
||||
// fetch((process.env.NEXT_PUBLIC_API_URL as string) + '/docs.json')
|
||||
// .then((res) => res.json())
|
||||
// .then((result) => {
|
||||
// setData(result);
|
||||
// setLoading(false);
|
||||
// })
|
||||
// .catch(() => {
|
||||
// setLoading(false);
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
// if (loading) {
|
||||
// return (
|
||||
// <div className="flex items-center justify-center min-h-screen">
|
||||
// <div className="h-12 w-12 rounded-full border-4 border-gray-300 border-t-blue-600 animate-spin" />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="flex items-center justify-center min-h-screen bg-primary text-primary transition-theme">
|
||||
// <Spinner />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
'use client';
|
||||
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { SidebarPanel } from '@/components/Sidebar';
|
||||
import {
|
||||
Slider,
|
||||
NumberInput,
|
||||
TextInput,
|
||||
Toggle,
|
||||
} from '../../../components/Inputs';
|
||||
|
||||
type PanelProps = {
|
||||
settings: Settings;
|
||||
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
temp: number;
|
||||
topP: number;
|
||||
topK: number | undefined;
|
||||
maxTokens: number | undefined;
|
||||
numCtx: number | undefined;
|
||||
stop: string[] | undefined;
|
||||
repeatPenalty: number;
|
||||
seed: number | undefined;
|
||||
stream: boolean;
|
||||
keepAlive: string | undefined;
|
||||
};
|
||||
|
||||
function SettingsPanel({ settings, setSettings }: PanelProps) {
|
||||
const set =
|
||||
<K extends keyof Settings>(key: K) =>
|
||||
(val: Settings[K]) =>
|
||||
setSettings((prev) => ({ ...prev, [key]: val }));
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Sampling',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<Slider
|
||||
label="Temperature"
|
||||
value={s.temp}
|
||||
setValue={set('temp')}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<Slider
|
||||
label="Top P"
|
||||
value={s.topP}
|
||||
setValue={set('topP')}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
/>
|
||||
<NumberInput label="Top K" value={s.topK} setValue={set('topK')} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Generation',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Max Tokens"
|
||||
value={s.maxTokens}
|
||||
setValue={set('maxTokens')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Context Size"
|
||||
value={s.numCtx}
|
||||
setValue={set('numCtx')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Stop Sequences"
|
||||
value={s.stop?.join(', ')}
|
||||
setValue={(val) =>
|
||||
set('stop')(val ? val.split(',').map((s) => s.trim()) : undefined)
|
||||
}
|
||||
placeholder="Comma separated"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Control',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<Slider
|
||||
label="Repeat Penalty"
|
||||
value={s.repeatPenalty}
|
||||
setValue={set('repeatPenalty')}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput label="Seed" value={s.seed} setValue={set('seed')} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<Toggle label="Streaming" value={s.stream} setValue={set('stream')} />
|
||||
<TextInput
|
||||
label="Keep Alive"
|
||||
value={s.keepAlive}
|
||||
setValue={set('keepAlive')}
|
||||
placeholder="e.g. 5m"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="space-y-4">
|
||||
<h3 className="text-xs uppercase text-accent tracking-widest">
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
{section.fields(settings, set)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SIDEBAR_PANELS = ({
|
||||
settings,
|
||||
setSettings,
|
||||
}: PanelProps): SidebarPanel[] => [
|
||||
{
|
||||
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 settings={settings} setSettings={setSettings} />,
|
||||
},
|
||||
];
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function DocPage() {
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
temp: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
maxTokens: 1000,
|
||||
numCtx: 128000,
|
||||
stop: [''],
|
||||
repeatPenalty: 1.1,
|
||||
seed: 0,
|
||||
stream: true,
|
||||
keepAlive: '5m',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ export default async function RootLayout(props: {
|
||||
|
||||
return (
|
||||
<html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}>
|
||||
<body className="min-h-screen flex flex-col bg-primary text-primary transition-theme">
|
||||
<body className="h-screen flex flex-col bg-primary text-primary transition-theme">
|
||||
<Providers locale={locale} messages={messages} session={session}>
|
||||
<Navbar />
|
||||
|
||||
<main className="flex-1 pt-20 flex flex-col">{props.children}</main>
|
||||
<main className="flex-1 pt-[var(--header-height)] flex overflow-hidden">
|
||||
{props.children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Providers>
|
||||
|
||||
@@ -1,17 +1,88 @@
|
||||
'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<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="min-h-screen flex flex-col items-center justify-center">
|
||||
<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 hover:bg-cyan-600 rounded-xl">
|
||||
<button
|
||||
className="mt-6 px-6 py-3 bg-cyan-500 rounded-xl"
|
||||
onClick={() => signIn('keycloak')}
|
||||
>
|
||||
{t('start')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatUI
|
||||
session={session}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
setSelectedModel={setOverrideModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
type SliderProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
setValue: (val: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
export function Slider({
|
||||
label,
|
||||
value,
|
||||
setValue,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: SliderProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs uppercase tracking-widest">{label}</label>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => setValue(Number(e.target.value))}
|
||||
className="flex-1 accent-violet-400"
|
||||
/>
|
||||
<span className="text-sm w-10 text-right">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NumberInputProps = {
|
||||
label: string;
|
||||
value?: number;
|
||||
setValue: (val: number | undefined) => void;
|
||||
};
|
||||
|
||||
export function NumberInput({ label, value, setValue }: NumberInputProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs uppercase tracking-widest">{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) =>
|
||||
setValue(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
className="w-full mt-2 p-2 rounded bg-black/20 border border-muted"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TextInputProps = {
|
||||
label: string;
|
||||
value?: string;
|
||||
setValue: (val: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function TextInput({
|
||||
label,
|
||||
value,
|
||||
setValue,
|
||||
placeholder,
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs uppercase tracking-widest">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value || undefined)}
|
||||
placeholder={placeholder}
|
||||
className="w-full mt-2 p-2 rounded bg-black/20 border border-muted"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ToggleProps = {
|
||||
label: string;
|
||||
value: boolean;
|
||||
setValue: (val: boolean) => void;
|
||||
};
|
||||
|
||||
export function Toggle({ label, value, setValue }: ToggleProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">{label}</span>
|
||||
<button
|
||||
onClick={() => setValue(!value)}
|
||||
className={`w-10 h-5 rounded-full ${
|
||||
value ? 'bg-violet-500' : 'bg-white/20'
|
||||
} relative`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-all ${
|
||||
value ? 'left-5' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
|
||||
export type SidebarPanel = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
component: React.ReactNode;
|
||||
};
|
||||
|
||||
function useIsWide(): boolean {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
window.addEventListener('resize', cb);
|
||||
return () => window.removeEventListener('resize', cb);
|
||||
},
|
||||
() => window.innerWidth >= 768,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Shared sidebar logic
|
||||
------------------------------*/
|
||||
function useSidebarState() {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const setClosed = () => setActiveId(null);
|
||||
|
||||
const toggle = (id: string, effectiveId: string | null) => {
|
||||
setActiveId((prev) =>
|
||||
prev === id || (effectiveId === id && prev === null) ? null : id,
|
||||
);
|
||||
};
|
||||
|
||||
const resolveEffectiveId = (isWide: boolean, panels: SidebarPanel[]) => {
|
||||
return activeId === null
|
||||
? null
|
||||
: (activeId ?? (isWide ? (panels[0]?.id ?? null) : null));
|
||||
};
|
||||
|
||||
return {
|
||||
activeId,
|
||||
setActiveId,
|
||||
setClosed,
|
||||
toggle,
|
||||
resolveEffectiveId,
|
||||
};
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Desktop UI
|
||||
------------------------------*/
|
||||
function DesktopSidebar({
|
||||
activePanel,
|
||||
isOpen,
|
||||
setClosed,
|
||||
}: {
|
||||
activePanel?: SidebarPanel;
|
||||
isOpen: boolean;
|
||||
setClosed: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex transition-all duration-300 ease-in-out overflow-hidden
|
||||
${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
{activePanel && (
|
||||
<div className="w-64 flex flex-col border-r border-muted">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0">
|
||||
<span className="text-sm font-semibold text-accent">
|
||||
{activePanel.label}
|
||||
</span>
|
||||
<button onClick={setClosed}>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 min-h-0">
|
||||
{activePanel.component}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Mobile UI (drawer)
|
||||
------------------------------*/
|
||||
function MobileSidebar({
|
||||
activePanel,
|
||||
setClosed,
|
||||
}: {
|
||||
activePanel?: SidebarPanel;
|
||||
setClosed: () => void;
|
||||
}) {
|
||||
if (!activePanel) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/40 z-20" onClick={setClosed} />
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed left-14 top-[var(--header-height)] bottom-0 z-30 bg-primary overflow-y-auto border border-muted w-64">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-muted">
|
||||
<span className="text-sm font-semibold text-accent">
|
||||
{activePanel.label}
|
||||
</span>
|
||||
|
||||
<button onClick={setClosed}>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3">{activePanel.component}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Icon rail (shared)
|
||||
------------------------------*/
|
||||
function IconRail({
|
||||
panels,
|
||||
effectiveId,
|
||||
toggle,
|
||||
}: {
|
||||
panels: SidebarPanel[];
|
||||
effectiveId: string | null;
|
||||
toggle: (id: string, effectiveId: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-12 flex flex-col items-center py-3 gap-1 z-30">
|
||||
{panels.map((panel) => (
|
||||
<button
|
||||
key={panel.id}
|
||||
onClick={() => toggle(panel.id, effectiveId)}
|
||||
title={panel.label}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center transition-theme ${
|
||||
effectiveId === panel.id ? 'bg-accent2' : ''
|
||||
}`}
|
||||
>
|
||||
{panel.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Main Sidebar (controller)
|
||||
------------------------------*/
|
||||
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
|
||||
const isWide = useIsWide();
|
||||
|
||||
const { setClosed, toggle, resolveEffectiveId } = useSidebarState();
|
||||
|
||||
const effectiveId = resolveEffectiveId(isWide, panels);
|
||||
const activePanel = panels.find((p) => p.id === effectiveId);
|
||||
const isOpen = !!activePanel;
|
||||
|
||||
return (
|
||||
<div className="flex border-r border-t border-muted">
|
||||
{/* Desktop */}
|
||||
{isWide && (
|
||||
<DesktopSidebar
|
||||
activePanel={activePanel}
|
||||
isOpen={isOpen}
|
||||
setClosed={setClosed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile */}
|
||||
{!isWide && (
|
||||
<MobileSidebar activePanel={activePanel} setClosed={setClosed} />
|
||||
)}
|
||||
|
||||
{/* Icon rail */}
|
||||
<IconRail panels={panels} effectiveId={effectiveId} toggle={toggle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'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';
|
||||
import { ChatRequest } from '@/types/chat-api';
|
||||
import { Settings } from './chat.panels';
|
||||
|
||||
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 [notification, setNotification] = useState<string | null>(null);
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
temp: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
maxTokens: 1000,
|
||||
numCtx: 128000,
|
||||
stop: [''],
|
||||
repeatPenalty: 1.1,
|
||||
seed: 0,
|
||||
stream: false,
|
||||
keepAlive: '5m',
|
||||
});
|
||||
|
||||
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 body: ChatRequest = {
|
||||
model: selectedModel!,
|
||||
messages: newMessages,
|
||||
temperature: settings.temp,
|
||||
top_p: settings.topP,
|
||||
top_k: settings.topK,
|
||||
num_predict: settings.maxTokens,
|
||||
num_ctx: settings.numCtx,
|
||||
repeat_penalty: settings.repeatPenalty,
|
||||
seed: settings.seed,
|
||||
stream: settings.stream,
|
||||
keep_alive: settings.keepAlive,
|
||||
stop: settings.stop,
|
||||
};
|
||||
|
||||
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(body),
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
const assistant = data?.choices?.[0]?.message;
|
||||
if (assistant) setMessages((prev) => [...prev, assistant]);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setNotification(
|
||||
err?.message || 'Something went wrong while sending request',
|
||||
);
|
||||
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 bg-primary text-primary transition-theme">
|
||||
{notification && (
|
||||
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50">
|
||||
{notification}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Sidebar (icon rail + sliding panel) ── */}
|
||||
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
|
||||
|
||||
{/* ── 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 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,218 @@
|
||||
import { SidebarPanel } from '../Sidebar';
|
||||
import { Slider, NumberInput, TextInput, Toggle } from '../Inputs';
|
||||
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
|
||||
type PanelProps = {
|
||||
settings: Settings;
|
||||
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
temp: number;
|
||||
topP: number;
|
||||
topK: number | undefined;
|
||||
maxTokens: number | undefined;
|
||||
numCtx: number | undefined;
|
||||
stop: string[] | undefined;
|
||||
repeatPenalty: number;
|
||||
seed: number | undefined;
|
||||
stream: boolean;
|
||||
keepAlive: string | undefined;
|
||||
};
|
||||
|
||||
function SettingsPanel({ settings, setSettings }: PanelProps) {
|
||||
const set =
|
||||
<K extends keyof Settings>(key: K) =>
|
||||
(val: Settings[K]) =>
|
||||
setSettings((prev) => ({ ...prev, [key]: val }));
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Sampling',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<Slider
|
||||
label="Temperature"
|
||||
value={s.temp}
|
||||
setValue={set('temp')}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<Slider
|
||||
label="Top P"
|
||||
value={s.topP}
|
||||
setValue={set('topP')}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
/>
|
||||
<NumberInput label="Top K" value={s.topK} setValue={set('topK')} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Generation',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Max Tokens"
|
||||
value={s.maxTokens}
|
||||
setValue={set('maxTokens')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Context Size"
|
||||
value={s.numCtx}
|
||||
setValue={set('numCtx')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Stop Sequences"
|
||||
value={s.stop?.join(', ')}
|
||||
setValue={(val) =>
|
||||
set('stop')(val ? val.split(',').map((s) => s.trim()) : undefined)
|
||||
}
|
||||
placeholder="Comma separated"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Control',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<Slider
|
||||
label="Repeat Penalty"
|
||||
value={s.repeatPenalty}
|
||||
setValue={set('repeatPenalty')}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput label="Seed" value={s.seed} setValue={set('seed')} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
fields: (s: any, set: any) => (
|
||||
<>
|
||||
<Toggle label="Streaming" value={s.stream} setValue={set('stream')} />
|
||||
<TextInput
|
||||
label="Keep Alive"
|
||||
value={s.keepAlive}
|
||||
setValue={set('keepAlive')}
|
||||
placeholder="e.g. 5m"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="space-y-4">
|
||||
<h3 className="text-xs uppercase text-accent tracking-widest">
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
{section.fields(settings, set)}
|
||||
</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 = ({
|
||||
settings,
|
||||
setSettings,
|
||||
}: PanelProps): 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 settings={settings} setSettings={setSettings} />,
|
||||
},
|
||||
{
|
||||
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,8 @@
|
||||
// src/components/icons/CloseIcon.tsx
|
||||
export function CloseIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" {...props}>
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export default function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-primary border-t border-border py-6 transition-theme">
|
||||
<footer className="bg-primary border-t h-64px border-border py-6 transition-theme">
|
||||
<div className="max-w-6xl mx-auto px-6 flex flex-col sm:flex-row justify-between items-center gap-3 transition-theme">
|
||||
<div className="flex items-center gap-1 font-bold text-accent text-sm transition-theme">
|
||||
<span className="bg-logo-light text-logo-dark px-1.5 py-0.5 rounded-l-md transition-theme">
|
||||
|
||||
@@ -19,6 +19,7 @@ export function LocaleSwitcher() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full py-2 hover:opacity-80">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/flags/${currentLocale}.svg`}
|
||||
alt={currentLocale}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function Navbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="fixed top-0 w-full z-50 bg-primary text-primary transition-theme">
|
||||
<nav className="fixed top-0 w-full z-50 h-[var(--header-height)] bg-primary text-primary transition-theme">
|
||||
<div className="w-full flex justify-between items-center px-4 sm:px-6 py-4 transition-theme">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
@@ -126,7 +126,7 @@ export default function Navbar() {
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`
|
||||
fixed top-[75px] left-0 z-40
|
||||
fixed top-[var(--header-height)] left-0 z-40
|
||||
w-full md:w-64 md:left-auto md:right-6
|
||||
origin-top transition-all duration-100
|
||||
${open ? 'opacity-100 scale-y-100' : 'opacity-0 scale-y-0 pointer-events-none'}
|
||||
|
||||
@@ -20,19 +20,20 @@ export default function ScrollProgressBar() {
|
||||
setScroll(percent);
|
||||
};
|
||||
|
||||
handleScroll(); // initial run
|
||||
handleScroll();
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="top-0 left-0 w-full h-[3px] z-10">
|
||||
<div className="top-0 left-0 w-full h-[3px] z-10 bg-[var(--muted-color)]">
|
||||
<div
|
||||
className="h-full transition-[width] duration-100 ease-out"
|
||||
style={{
|
||||
width: `${scroll}%`,
|
||||
background: 'linear-gradient(to right, var(--accent), #7b2ff7)',
|
||||
background:
|
||||
'linear-gradient(to right, var(--accent), var(--accent2))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
type SpinnerProps = {
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export default function Spinner({ text = '' }: SpinnerProps) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center min-h-screen bg-primary text-primary transition-theme">
|
||||
<div className="absolute text-muted text-sm tracking-widest">{text}</div>
|
||||
|
||||
{/* Spinner */}
|
||||
<div className="relative h-14 w-14">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-muted transition-theme" />
|
||||
<div className="absolute inset-0 rounded-full border-4 border-transparent border-t-[var(--accent)] animate-spin transition-theme" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-1
@@ -4,7 +4,10 @@
|
||||
GLOBAL BASE STYLES
|
||||
========================= */
|
||||
|
||||
html,
|
||||
html {
|
||||
scroll-padding-top: var(--header-height);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
@@ -17,11 +20,18 @@ body {
|
||||
========================= */
|
||||
|
||||
:root {
|
||||
--header-height: 75px;
|
||||
--footer-height: 70px;
|
||||
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: oklch(27.4% 0.006 286.033);
|
||||
|
||||
--text-primary: #ffffff;
|
||||
|
||||
--accent: #00d4ff;
|
||||
--accent2: #7b2ff7;
|
||||
|
||||
--muted-color: #ffffff33;
|
||||
|
||||
--color-base: #000000;
|
||||
--color-inverse: #ffffff;
|
||||
@@ -37,11 +47,14 @@ body {
|
||||
|
||||
.light-mode {
|
||||
--bg-primary: #e0e0e0;
|
||||
--bg-secondary: oklch(96.7% 0.001 286.375);
|
||||
|
||||
--text-primary: #111111;
|
||||
|
||||
--accent: #0077ff;
|
||||
|
||||
--muted-color: #ffffff33;
|
||||
|
||||
--color-base: #ffffff;
|
||||
--color-inverse: #000000;
|
||||
}
|
||||
@@ -54,6 +67,18 @@ body {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.bg-secondary {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.bg-accent {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.bg-accent2 {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -62,6 +87,18 @@ body {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.border-muted {
|
||||
border-color: var(--muted-color);
|
||||
}
|
||||
|
||||
.transition-fast {
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
TRANSITIONS
|
||||
========================= */
|
||||
|
||||
+50
-32
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function getCookie(name: string) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,40 +18,58 @@ function setCookie(value: Theme) {
|
||||
document.cookie = `theme=${value}; path=/; max-age=31536000`;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
function readThemeFromDOM(): Theme {
|
||||
const cookieTheme = getCookie('theme') as Theme | null;
|
||||
const rootTheme = document.documentElement.classList.contains('dark')
|
||||
? 'dark'
|
||||
: 'light';
|
||||
if (cookieTheme === 'light' || cookieTheme === 'dark') return cookieTheme;
|
||||
if (document.documentElement.classList.contains('dark')) return 'dark';
|
||||
return 'light';
|
||||
}
|
||||
|
||||
const initial = cookieTheme || rootTheme || 'dark';
|
||||
|
||||
setTheme(initial);
|
||||
|
||||
document.documentElement.classList.toggle('dark', initial === 'dark');
|
||||
document.documentElement.classList.toggle(
|
||||
'light-mode',
|
||||
initial === 'light',
|
||||
);
|
||||
}, []);
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
function applyThemeToDOM(t: Theme) {
|
||||
document.documentElement.classList.toggle('dark', t === 'dark');
|
||||
document.documentElement.classList.toggle('light-mode', t === 'light');
|
||||
}
|
||||
|
||||
setCookie(t);
|
||||
}
|
||||
// ── External store ────────────────────────────────────────────────────────────
|
||||
// useSyncExternalStore requires a stable subscribe function and a getSnapshot.
|
||||
// We use a simple Set of listeners to notify React when the theme changes.
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme((prev) => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
const listeners = new Set<() => void>();
|
||||
let clientTheme: Theme | null = null; // lazily initialised on first client read
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
if (clientTheme === null) {
|
||||
clientTheme = readThemeFromDOM();
|
||||
}
|
||||
return clientTheme;
|
||||
}
|
||||
|
||||
// The server always returns the default theme so SSR HTML is deterministic.
|
||||
// The client will then reconcile with the real cookie/DOM value via getSnapshot.
|
||||
function getServerSnapshot(): Theme {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function setGlobalTheme(next: Theme) {
|
||||
clientTheme = next;
|
||||
applyThemeToDOM(next);
|
||||
setCookie(next);
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTheme() {
|
||||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setGlobalTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
}, [theme]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export type ChatRequest = {
|
||||
model: string;
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
repeat_penalty?: number;
|
||||
seed?: number;
|
||||
num_ctx?: number;
|
||||
num_predict?: number;
|
||||
stop?: string[];
|
||||
keep_alive?: string;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export type ChatCompletionResponse = {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: ChatChoice[];
|
||||
usage?: Usage;
|
||||
};
|
||||
|
||||
export type FinishReason = 'Stop' | 'Length' | 'ContentFilter' | 'ToolCalls';
|
||||
|
||||
export type ChatChoice = {
|
||||
index: number;
|
||||
message: Message;
|
||||
finish_reason: FinishReason;
|
||||
};
|
||||
|
||||
export type Usage = {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
Reference in New Issue
Block a user