Compare commits
28
Commits
a43f8de3e4
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36745cc9c4 | ||
|
|
ca3abb19e1 | ||
|
|
7a910b3d8f | ||
|
|
bad3b5539c | ||
|
|
1f7164ed2b | ||
|
|
b006b07e63 | ||
|
|
1fe5acc871 | ||
|
|
a5b2dc6fef | ||
|
|
df0428f240 | ||
|
|
6e409592b5 | ||
|
|
18f293ce67 | ||
|
|
d04fbbe73c | ||
|
|
11b5e6929a | ||
|
|
07731e888c | ||
|
|
4e2a60b792 | ||
|
|
df2c8aa766 | ||
|
|
97cbc1d543 | ||
|
|
c4ebb599b6 | ||
|
|
e2266b7bd7 | ||
|
|
1c7d22e04d | ||
|
|
cae66b50e1 | ||
|
|
fdbbdc2f22 | ||
|
|
4f20ff33ae | ||
|
|
92ef6fa5a9 | ||
|
|
96954a4d7f | ||
|
|
92305c1a12 | ||
|
|
45b64991eb | ||
|
|
af3fada37a |
@@ -7,4 +7,5 @@ Dockerfile
|
|||||||
*.log
|
*.log
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
.env*
|
.env*
|
||||||
|
!.env.production
|
||||||
.next/cache
|
.next/cache
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=https://chat.iceberg.black/api/chat/v1
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
|
|
||||||
scp -i key.pem -o StrictHostKeyChecking=no \
|
scp -i key.pem -o StrictHostKeyChecking=no \
|
||||||
docker-compose.yml \
|
docker-compose.yml \
|
||||||
bot@${{ secrets.SSH_HOST }}:/home/shared/iceberg/.../docker-compose.yml # [TO CHANGE]
|
bot@${{ secrets.SSH_HOST }}:/home/shared/iceberg/chat/chat-website/docker-compose.yml
|
||||||
- name: Execute remote deployment
|
- name: Execute remote deployment
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
@@ -73,10 +73,10 @@ jobs:
|
|||||||
docker pull gitea.iceberg.black/$REPO_LOWER:$TAG
|
docker pull gitea.iceberg.black/$REPO_LOWER:$TAG
|
||||||
docker logout gitea.iceberg.black
|
docker logout gitea.iceberg.black
|
||||||
|
|
||||||
docker stop ... || true # [TO CHANGE]
|
docker stop chat-website || true
|
||||||
docker rm ... || true # [TO CHANGE]
|
docker rm chat-website || true
|
||||||
|
|
||||||
cd /home/shared/iceberg/... # [TO CHANGE]
|
cd /home/shared/iceberg/chat/chat-website
|
||||||
REPO_LOWER=$REPO_LOWER TAG=$TAG docker compose up -d
|
REPO_LOWER=$REPO_LOWER TAG=$TAG docker compose up -d
|
||||||
|
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.production
|
||||||
|
|
||||||
# husky
|
# husky
|
||||||
.husky
|
.husky
|
||||||
@@ -39,3 +40,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
src/app/api/auth/debug
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Change all lines with [TO CHANGE] + public/manifest.json
|
|||||||
|
|
||||||
- Ensure all git secrets or variables exist
|
- Ensure all git secrets or variables exist
|
||||||
- Ensure all docker links exist on distant machine
|
- Ensure all docker links exist on distant machine
|
||||||
- Ensure env file exists on remote machine
|
- Ensure env file exists on remote machine. If client needs access to env var put it in .env.production so var in included at build (DONT PUT SECRETS).
|
||||||
|
|
||||||
## Auto deploy
|
## Auto deploy
|
||||||
|
|
||||||
@@ -141,5 +141,9 @@ git tag -d v1.0.0; git push origin :refs/tags/v1.0.0; git tag -a v1.0.0 -m "Rele
|
|||||||
|
|
||||||
## To implement
|
## To implement
|
||||||
|
|
||||||
- Better Tailwind CSS classes
|
- Token refresh
|
||||||
- See keycloak user infos
|
- No error when streaming with too large model
|
||||||
|
- Translations
|
||||||
|
- When user stop generation, forward it to server
|
||||||
|
- Message UI
|
||||||
|
- UX (eg keep focus when press enter)
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
chat:
|
chat:
|
||||||
image: gitea.iceberg.black/${REPO_LOWER}:${TAG}
|
image: gitea.iceberg.black/${REPO_LOWER}:${TAG}
|
||||||
container_name: ... # [TO CHANGE]
|
container_name: chat-website
|
||||||
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env # You SHOULD upload env file on vps before
|
- .env # You SHOULD upload env file on vps before
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- '...:...' # [TO CHANGE]
|
- '6067:3000'
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+2
-2
@@ -21,11 +21,11 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"label1": "Home",
|
"label1": "Home",
|
||||||
"label2": "Example",
|
"label2": "Example",
|
||||||
"label3": "Home 2"
|
"label3": "Doc"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"name": "Iceberg Server",
|
"name": "Iceberg Server",
|
||||||
"welcome": "Welcome to your Vite + React + Tailwind app",
|
"welcome": "Welcome to Iceberg Chat. Log in to start.",
|
||||||
"start": "Get Started"
|
"start": "Get Started"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -21,11 +21,11 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"label1": "Accueil",
|
"label1": "Accueil",
|
||||||
"label2": "Exemple",
|
"label2": "Exemple",
|
||||||
"label3": "Accueil 2"
|
"label3": "Doc"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"name": "Iceberg Server",
|
"name": "Iceberg Server",
|
||||||
"welcome": "Bienvenue sur votre application Vite + React + Tailwind",
|
"welcome": "Bienvenue sur Iceberg Chat. Veuillez vous connecter pour continuer.",
|
||||||
"start": "Commencer"
|
"start": "Commencer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Iceberg Server",
|
"name": "Chat Iceberg",
|
||||||
"short_name": "Iceberg",
|
"short_name": "Chat Iceberg",
|
||||||
"description": "Serveur personnel Iceberg - Petit mais costaud",
|
"description": "Privacy-aware LLM",
|
||||||
|
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
export default function GenerateKeyButton() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(process.env.NEXT_PUBLIC_API_URL! + '/keys/generate', {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${session?.accessToken}`,
|
||||||
|
// "x-api-key": "Q67lQp7NZY2f2Zy6DXYbFoauDMjw79FBD8LPn9ee6OM"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "my-api-key",
|
||||||
|
scopes: ["read", "write"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
throw new Error(errText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setResult(JSON.stringify(data, null, 2));
|
||||||
|
} catch (err: any) {
|
||||||
|
setResult(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
background: "black",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Generating..." : "Generate API Key"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<pre style={{ marginTop: 20, background: "#eee", padding: 10 }}>
|
||||||
|
{result}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export default function AboutPage() {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1>About</h1>
|
|
||||||
<p>This is an example</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,9 @@ import { routing } from '@/i18n/routing';
|
|||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { Providers } from '../providers';
|
import { Providers } from '../providers';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from '@/lib/auth';
|
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import SessionGuard from '@/components/SessionGuard';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Iceberg template',
|
title: 'Iceberg template',
|
||||||
@@ -40,13 +41,17 @@ export default async function RootLayout(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}>
|
<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}>
|
<Providers locale={locale} messages={messages} session={session}>
|
||||||
<Navbar />
|
<SessionGuard>
|
||||||
|
<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 />
|
<Footer />
|
||||||
|
</SessionGuard>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,17 +1,88 @@
|
|||||||
|
'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-1 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,9 @@
|
|||||||
|
import { env } from "process";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const res = await fetch(env.NEXT_PUBLIC_API_URL! + "/docs.json");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
return Response.json(data);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export function Providers({
|
|||||||
messages: AbstractIntlMessages;
|
messages: AbstractIntlMessages;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider session={session}>
|
<SessionProvider session={session} refetchInterval={4 * 60} refetchOnWindowFocus={true}>
|
||||||
<NextIntlClientProvider
|
<NextIntlClientProvider
|
||||||
locale={locale}
|
locale={locale}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
|||||||
@@ -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-[var(--accent2)]"
|
||||||
|
/>
|
||||||
|
<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-secondary 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-secondary 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 border border-muted ${
|
||||||
|
value ? 'bg-[var(--accent2)]' : 'bg-secondary'
|
||||||
|
} 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,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession, signIn } from "next-auth/react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Spinner from "./Spinner";
|
||||||
|
|
||||||
|
export default function SessionGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const triedSilent = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated" && !triedSilent.current) {
|
||||||
|
triedSilent.current = true;
|
||||||
|
signIn("keycloak", undefined, { prompt: "none" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
session?.error === "RefreshTokenExpired" ||
|
||||||
|
session?.error === "RefreshAccessTokenError"
|
||||||
|
) {
|
||||||
|
signIn("keycloak", undefined, { prompt: "none" });
|
||||||
|
}
|
||||||
|
}, [status, session?.error]);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return <Spinner />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'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(panels: SidebarPanel[], isWide: boolean) {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// open first panel by default ONLY on first mount (desktop only)
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
if (!initialized && isWide) {
|
||||||
|
setActiveId(panels[0]?.id ?? null);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClosed = () => setActiveId(null);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setActiveId((prev) => (prev === id ? null : id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeId,
|
||||||
|
setActiveId,
|
||||||
|
setClosed,
|
||||||
|
toggle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
Desktop UI
|
||||||
|
------------------------------*/
|
||||||
|
function DesktopSidebar({
|
||||||
|
activePanel,
|
||||||
|
isOpen,
|
||||||
|
setClosed,
|
||||||
|
}: {
|
||||||
|
activePanel?: SidebarPanel;
|
||||||
|
isOpen: boolean;
|
||||||
|
setClosed: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex transition-all duration-300 ease overflow-hidden
|
||||||
|
${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{activePanel && (
|
||||||
|
<div className="w-64 flex flex-col border-r border-muted transition-theme">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0 transition-theme">
|
||||||
|
<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 text-secondary ${
|
||||||
|
effectiveId === panel.id ? 'border-2 border-[var(--accent)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{panel.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
Main Sidebar (controller)
|
||||||
|
------------------------------*/
|
||||||
|
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
|
||||||
|
const isWide = useIsWide();
|
||||||
|
|
||||||
|
const { activeId, setClosed, toggle } = useSidebarState(panels, isWide);
|
||||||
|
|
||||||
|
const effectiveId = activeId;
|
||||||
|
const activePanel = panels.find((p) => p.id === effectiveId);
|
||||||
|
const isOpen = !!activePanel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex border-r border-t border-muted transition-theme">
|
||||||
|
{/* 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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } 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';
|
||||||
|
import { fetchChatStream, readStreamChunks } from './stream';
|
||||||
|
import { MessageIcon } from '../icons/MessageIcon';
|
||||||
|
import { CancelIcon } from '../icons/CancelIcon';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
session: Session;
|
||||||
|
models: ModelInfo[];
|
||||||
|
selectedModel: string | null;
|
||||||
|
setSelectedModel: (v: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sentinel role used while the assistant message is being streamed in
|
||||||
|
const ASSISTANT_ROLE = 'assistant' as const;
|
||||||
|
|
||||||
|
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: true,
|
||||||
|
keepAlive: '5m',
|
||||||
|
});
|
||||||
|
|
||||||
|
const showError = (message: string) => {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: ASSISTANT_ROLE, content: `❌ ${message}` },
|
||||||
|
]);
|
||||||
|
setNotification(message);
|
||||||
|
setTimeout(() => setNotification(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!input.trim() || !selectedModel) return;
|
||||||
|
|
||||||
|
const userMessage: Message = { role: 'user', content: input };
|
||||||
|
const newMessages: Message[] = [...messages, userMessage];
|
||||||
|
setMessages(newMessages);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
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 endpoint = process.env.NEXT_PUBLIC_API_URL + '/chat/completions';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (settings.stream) {
|
||||||
|
await handleStream(endpoint, body, newMessages);
|
||||||
|
} else {
|
||||||
|
await handleNonStream(endpoint, body);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
showError(err?.message ?? 'Something went wrong while sending request');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Non-streaming path ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleNonStream = async (endpoint: string, body: ChatRequest) => {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${session?.accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = 'Request failed';
|
||||||
|
if (res.status === 500)
|
||||||
|
message = 'Server error (likely model too large or OOM)';
|
||||||
|
else if (res.status === 401) message = 'Unauthorized';
|
||||||
|
else message = data?.message ?? data?.error ?? message;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = data?.choices?.[0]?.message;
|
||||||
|
if (assistant) {
|
||||||
|
setMessages((prev) => [...prev, assistant]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Streaming path ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const stopStream = () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStream = async (
|
||||||
|
endpoint: string,
|
||||||
|
body: ChatRequest,
|
||||||
|
priorMessages: Message[],
|
||||||
|
) => {
|
||||||
|
const placeholderIndex = priorMessages.length;
|
||||||
|
setMessages((prev) => [...prev, { role: ASSISTANT_ROLE, content: '' }]);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
const response = await fetchChatStream(
|
||||||
|
endpoint,
|
||||||
|
body,
|
||||||
|
session?.accessToken as string,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const { content, done } of readStreamChunks(response)) {
|
||||||
|
if (done) break;
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const target = next[placeholderIndex];
|
||||||
|
if (target) {
|
||||||
|
next[placeholderIndex] = {
|
||||||
|
...target,
|
||||||
|
content: target.content + content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// AbortError is expected when the user clicks Stop — don't rethrow
|
||||||
|
if (err?.name !== 'AbortError') throw err;
|
||||||
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 bg-primary text-primary transition-theme relative">
|
||||||
|
{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 panels={SIDEBAR_PANELS({ settings, setSettings })} />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<header className="p-4 flex justify-between items-center border-b border-white/10">
|
||||||
|
<ModelSelect
|
||||||
|
models={models}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
setSelectedModel={setSelectedModel}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto flex flex-col p-6">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center">
|
||||||
|
<p className="text-primary text-sm mb-3">Start a conversation…</p>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === 'Enter' && !loading && sendMessage()
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full max-w-2xl p-3 rounded-xl bg-secondary border border-muted outline-none disabled:opacity-50"
|
||||||
|
placeholder="Message..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
{loading &&
|
||||||
|
settings.stream &&
|
||||||
|
i === messages.length - 1 &&
|
||||||
|
msg.role === ASSISTANT_ROLE && (
|
||||||
|
<span className="inline-block w-[2px] h-[1em] bg-current ml-0.5 align-middle animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && !settings.stream && (
|
||||||
|
<div className="text-xs text-muted">Thinking...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center w-full max-w-2xl mx-auto relative">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === 'Enter' && !loading && sendMessage()
|
||||||
|
}
|
||||||
|
className="w-full p-3 pr-12 rounded-xl bg-secondary border border-muted outline-none"
|
||||||
|
placeholder="Message..."
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 flex items-center">
|
||||||
|
{loading ? (
|
||||||
|
<button
|
||||||
|
onClick={stopStream}
|
||||||
|
className="group p-2 rounded-lg"
|
||||||
|
title="Stop generation"
|
||||||
|
>
|
||||||
|
<CancelIcon className="group-hover:text-[var(--accent)]" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={!input.trim() || !selectedModel}
|
||||||
|
className="group p-2 rounded-lg disabled:opacity-30 disabled:pointer-events-none"
|
||||||
|
title="Send"
|
||||||
|
>
|
||||||
|
<MessageIcon className="group-hover:group-disabled:hover:text-inherit group-hover:text-[var(--accent)]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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-theme"
|
||||||
|
>
|
||||||
|
<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-secondary">{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,123 @@
|
|||||||
|
/**
|
||||||
|
* chat.stream.ts
|
||||||
|
*
|
||||||
|
* Helpers for consuming a streaming chat-completions response (SSE format).
|
||||||
|
* The server must send chunks as:
|
||||||
|
* data: {"choices":[{"delta":{"content":"..."}}]}\n\n
|
||||||
|
* data: [DONE]\n\n
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StreamChunk = {
|
||||||
|
content: string;
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async-generator that yields text deltas from a streaming fetch response.
|
||||||
|
* Handles both pure SSE (`data: ...`) and raw JSON-lines.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* for await (const { content, done } of readStreamChunks(response)) {
|
||||||
|
* if (done) break;
|
||||||
|
* setPartial(prev => prev + content);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function* readStreamChunks(
|
||||||
|
response: Response,
|
||||||
|
): AsyncGenerator<StreamChunk> {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null — cannot stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
// Flush any remaining buffer content
|
||||||
|
if (buffer.trim()) {
|
||||||
|
yield* parseBuffer(buffer);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Split on double-newline (SSE event boundary) or single newline (JSON-lines)
|
||||||
|
const parts = buffer.split(/\n\n|\n/);
|
||||||
|
|
||||||
|
// Keep the last (potentially incomplete) chunk in the buffer
|
||||||
|
buffer = parts.pop() ?? '';
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
yield* parseBuffer(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single SSE line or JSON line and yields a StreamChunk.
|
||||||
|
*/
|
||||||
|
function* parseBuffer(raw: string): Generator<StreamChunk> {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
// SSE lines start with "data: "
|
||||||
|
const jsonStr = line.startsWith('data: ') ? line.slice(6) : line;
|
||||||
|
|
||||||
|
if (jsonStr === '[DONE]') {
|
||||||
|
yield { content: '', done: true };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||||
|
if (typeof delta === 'string') {
|
||||||
|
yield { content: delta, done: false };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — silently skip (could be a comment or keep-alive ping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the streaming fetch request and returns the raw Response.
|
||||||
|
* Throws on non-2xx status with a descriptive message.
|
||||||
|
*/
|
||||||
|
export async function fetchChatStream(
|
||||||
|
url: string,
|
||||||
|
body: object,
|
||||||
|
token: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = 'Request failed';
|
||||||
|
|
||||||
|
if (res.status === 500) {
|
||||||
|
message = 'Server error (likely model too large or OOM)';
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
message = 'Unauthorized';
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
message = data?.message ?? data?.error ?? message;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const CancelIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
{...props}
|
||||||
|
className={`w-4 h-4 ${props.className ?? ''}`}
|
||||||
|
>
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function MessageIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
className={`w-4 h-4 ${props.className ?? ''}`}
|
||||||
|
>
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13" />
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ export default function Footer() {
|
|||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
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="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">
|
<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">
|
<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 (
|
return (
|
||||||
<div className="flex items-center gap-2 w-full py-2 hover:opacity-80">
|
<div className="flex items-center gap-2 w-full py-2 hover:opacity-80">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={`/flags/${currentLocale}.svg`}
|
src={`/flags/${currentLocale}.svg`}
|
||||||
alt={currentLocale}
|
alt={currentLocale}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function Navbar() {
|
|||||||
const links = [
|
const links = [
|
||||||
{ to: '/', label: t('label1') },
|
{ to: '/', label: t('label1') },
|
||||||
{ to: '/example', label: t('label2') },
|
{ to: '/example', label: t('label2') },
|
||||||
{ to: '/3', label: t('label3') },
|
{ to: '/doc', label: t('label3') },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +77,7 @@ export default function Navbar() {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="w-full flex justify-between items-center px-4 sm:px-6 py-4 transition-theme">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link
|
<Link
|
||||||
@@ -126,7 +126,7 @@ export default function Navbar() {
|
|||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={`
|
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
|
w-full md:w-64 md:left-auto md:right-6
|
||||||
origin-top transition-all duration-100
|
origin-top transition-all duration-100
|
||||||
${open ? 'opacity-100 scale-y-100' : 'opacity-0 scale-y-0 pointer-events-none'}
|
${open ? 'opacity-100 scale-y-100' : 'opacity-0 scale-y-0 pointer-events-none'}
|
||||||
|
|||||||
@@ -20,19 +20,20 @@ export default function ScrollProgressBar() {
|
|||||||
setScroll(percent);
|
setScroll(percent);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleScroll(); // initial run
|
handleScroll();
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="h-full transition-[width] duration-100 ease-out"
|
className="h-full transition-[width] duration-100 ease-out"
|
||||||
style={{
|
style={{
|
||||||
width: `${scroll}%`,
|
width: `${scroll}%`,
|
||||||
background: 'linear-gradient(to right, var(--accent), #7b2ff7)',
|
background:
|
||||||
|
'linear-gradient(to right, var(--accent), var(--accent2))',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+58
-1
@@ -4,7 +4,10 @@
|
|||||||
GLOBAL BASE STYLES
|
GLOBAL BASE STYLES
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
html,
|
html {
|
||||||
|
scroll-padding-top: var(--header-height);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -17,11 +20,19 @@ body {
|
|||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--header-height: 75px;
|
||||||
|
--footer-height: 70px;
|
||||||
|
|
||||||
--bg-primary: #000000;
|
--bg-primary: #000000;
|
||||||
|
--bg-secondary: #27272a;
|
||||||
|
|
||||||
--text-primary: #ffffff;
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #bbbbbb;
|
||||||
|
|
||||||
--accent: #00d4ff;
|
--accent: #00d4ff;
|
||||||
|
--accent2: #7b2ff7;
|
||||||
|
|
||||||
|
--muted-color: #ffffff33;
|
||||||
|
|
||||||
--color-base: #000000;
|
--color-base: #000000;
|
||||||
--color-inverse: #ffffff;
|
--color-inverse: #ffffff;
|
||||||
@@ -37,11 +48,15 @@ body {
|
|||||||
|
|
||||||
.light-mode {
|
.light-mode {
|
||||||
--bg-primary: #e0e0e0;
|
--bg-primary: #e0e0e0;
|
||||||
|
--bg-secondary: #dadada;
|
||||||
|
|
||||||
--text-primary: #111111;
|
--text-primary: #111111;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
|
||||||
--accent: #0077ff;
|
--accent: #0077ff;
|
||||||
|
|
||||||
|
--muted-color: #88888833;
|
||||||
|
|
||||||
--color-base: #ffffff;
|
--color-base: #ffffff;
|
||||||
--color-inverse: #000000;
|
--color-inverse: #000000;
|
||||||
}
|
}
|
||||||
@@ -54,21 +69,63 @@ body {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-accent {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-accent2 {
|
||||||
|
background: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.text-accent {
|
.text-accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-muted {
|
||||||
|
border-color: var(--muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-fast {
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
TRANSITIONS
|
TRANSITIONS
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
|
@property --bg-secondary {
|
||||||
|
syntax: '<color>';
|
||||||
|
inherits: true;
|
||||||
|
initial-value: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --bg-primary {
|
||||||
|
syntax: '<color>';
|
||||||
|
inherits: true;
|
||||||
|
initial-value: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.transition-theme {
|
.transition-theme {
|
||||||
transition:
|
transition:
|
||||||
|
--bg-secondary 500ms ease,
|
||||||
|
--bg-primary 500ms ease,
|
||||||
color 500ms ease,
|
color 500ms ease,
|
||||||
background-color 500ms ease,
|
background-color 500ms ease,
|
||||||
border-color 500ms ease;
|
border-color 500ms ease;
|
||||||
|
|||||||
+55
-37
@@ -1,16 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
function getCookie(name: string) {
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
if (typeof document === 'undefined') return null;
|
|
||||||
|
|
||||||
|
function getCookie(name: string): string | null {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,40 +18,58 @@ function setCookie(value: Theme) {
|
|||||||
document.cookie = `theme=${value}; path=/; max-age=31536000`;
|
document.cookie = `theme=${value}; path=/; max-age=31536000`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readThemeFromDOM(): Theme {
|
||||||
|
const cookieTheme = getCookie('theme') as Theme | null;
|
||||||
|
if (cookieTheme === 'light' || cookieTheme === 'dark') return cookieTheme;
|
||||||
|
if (document.documentElement.classList.contains('dark')) return 'dark';
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeToDOM(t: Theme) {
|
||||||
|
document.documentElement.classList.toggle('dark', t === 'dark');
|
||||||
|
document.documentElement.classList.toggle('light-mode', t === 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
|
||||||
|
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() {
|
export function useTheme() {
|
||||||
const [theme, setTheme] = useState<Theme>('dark');
|
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
const cookieTheme = getCookie('theme') as Theme | null;
|
setGlobalTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
const rootTheme = document.documentElement.classList.contains('dark')
|
}, [theme]);
|
||||||
? 'dark'
|
|
||||||
: '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) {
|
|
||||||
document.documentElement.classList.toggle('dark', t === 'dark');
|
|
||||||
document.documentElement.classList.toggle('light-mode', t === 'light');
|
|
||||||
|
|
||||||
setCookie(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTheme() {
|
|
||||||
setTheme((prev) => {
|
|
||||||
const next = prev === 'dark' ? 'light' : 'dark';
|
|
||||||
applyTheme(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
|
|||||||
+157
-70
@@ -1,30 +1,34 @@
|
|||||||
import { NextAuthOptions } from 'next-auth';
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import KeycloakProvider from 'next-auth/providers/keycloak';
|
import { NextAuthOptions } from "next-auth";
|
||||||
import { JWT } from 'next-auth/jwt';
|
import { JWT } from "next-auth/jwt";
|
||||||
|
|
||||||
declare module 'next-auth' {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
accessToken?: string;
|
accessToken: string;
|
||||||
roles?: string[];
|
idToken: string;
|
||||||
|
roles: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'next-auth/jwt' {
|
declare module "next-auth/jwt" {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
accessToken?: string;
|
accessToken: string;
|
||||||
refreshToken?: string;
|
idToken: string;
|
||||||
accessTokenExpires?: number;
|
refreshToken: string;
|
||||||
|
accessTokenExpiresAt: number;
|
||||||
|
refreshTokenExpiresAt: number;
|
||||||
|
roles: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
roles?: string[];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decode(token: string) {
|
const NEVER_EXPIRES = 9999999999;
|
||||||
try {
|
|
||||||
const base64 = token.split('.')[1];
|
|
||||||
|
|
||||||
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
|
export function decode(token: string) {
|
||||||
|
try {
|
||||||
|
const base64 = token.split(".")[1];
|
||||||
|
return JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -33,46 +37,62 @@ function decode(token: string) {
|
|||||||
async function refreshAccessToken(token: JWT): Promise<JWT> {
|
async function refreshAccessToken(token: JWT): Promise<JWT> {
|
||||||
try {
|
try {
|
||||||
if (!token.refreshToken) {
|
if (!token.refreshToken) {
|
||||||
throw new Error('No refresh token');
|
throw new Error("Missing refresh token");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
|
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
grant_type: 'refresh_token',
|
grant_type: "refresh_token",
|
||||||
refresh_token: token.refreshToken,
|
refresh_token: token.refreshToken,
|
||||||
}),
|
}),
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Refresh failed');
|
throw new Error(`Refresh failed: ${response.status} ${JSON.stringify(data)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await response.json();
|
const access = decode(data.access_token);
|
||||||
|
const refresh = decode(data.refresh_token);
|
||||||
const decoded = decode(refreshed.access_token);
|
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
accessToken: refreshed.access_token,
|
|
||||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
accessToken: data.access_token!,
|
||||||
accessTokenExpires: decoded?.exp
|
refreshToken: data.refresh_token!,
|
||||||
? decoded.exp * 1000
|
idToken: data.id_token!,
|
||||||
: Date.now() + 5 * 60 * 1000,
|
|
||||||
|
accessTokenExpiresAt: access.exp!,
|
||||||
|
refreshTokenExpiresAt: refresh.exp ?? NEVER_EXPIRES,
|
||||||
|
|
||||||
|
roles: [
|
||||||
|
...new Set([
|
||||||
|
...(access?.realm_access?.roles ?? []),
|
||||||
|
...(access?.resource_access?.[clientId]?.roles ?? []),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
} catch {
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[refreshAccessToken] failed:", err);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
error: 'RefreshAccessTokenError',
|
error: "RefreshAccessTokenError",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,67 +103,134 @@ export const authOptions: NextAuthOptions = {
|
|||||||
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
issuer: process.env.KEYCLOAK_ISSUER!,
|
issuer: process.env.KEYCLOAK_ISSUER!,
|
||||||
}),
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: "openid offline_access",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
],
|
],
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account }) {
|
async jwt({ token, account }) {
|
||||||
if (token.error === 'RefreshAccessTokenError') return token;
|
const now = Math.floor(Date.now() / 1000);
|
||||||
// initial login
|
|
||||||
if (account) {
|
|
||||||
const decoded = decode(account.access_token!);
|
|
||||||
|
|
||||||
|
// 1. LOGIN
|
||||||
|
if (account) {
|
||||||
|
console.log("[jwt] LOGIN - building initial token state");
|
||||||
|
|
||||||
|
const decodedRefreshToken = decode(account.refresh_token!);
|
||||||
|
const decodedAccessToken = decode(account.access_token!);
|
||||||
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||||
|
|
||||||
const realmRoles = decoded?.realm_access?.roles ?? [];
|
token.accessToken = account.access_token!;
|
||||||
const clientRoles = decoded?.resource_access?.[clientId]?.roles ?? [];
|
token.refreshToken = account.refresh_token!;
|
||||||
|
token.idToken = account.id_token!;
|
||||||
token.roles = [...new Set([...realmRoles, ...clientRoles])];
|
token.accessTokenExpiresAt = account.expires_at!;
|
||||||
|
token.sub = decodedAccessToken.sub!
|
||||||
token.accessToken = account.access_token;
|
token.refreshTokenExpiresAt = decodedRefreshToken.exp ?? NEVER_EXPIRES;
|
||||||
token.refreshToken = account.refresh_token;
|
token.roles = [
|
||||||
token.accessTokenExpires = decoded?.exp
|
...new Set([
|
||||||
? decoded.exp * 1000
|
...(decodedAccessToken?.realm_access?.roles ?? []),
|
||||||
: Date.now() + 5 * 60 * 1000;
|
...(decodedAccessToken?.resource_access?.[clientId]?.roles ?? []),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
token.error = undefined;
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loop if refresh already failed
|
// 2. SESSION EXPIRED → FORCE LOGIN
|
||||||
if (
|
if (now >= token.refreshTokenExpiresAt) {
|
||||||
token.accessTokenExpires &&
|
return { ...token, error: "RefreshTokenExpired" };
|
||||||
Date.now() > token.accessTokenExpires - 60_000
|
|
||||||
) {
|
|
||||||
return { ...token, error: 'RefreshAccessTokenError' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// still valid
|
// 3. ACCESS TOKEN STILL VALID
|
||||||
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
|
if (now < token.accessTokenExpiresAt - 60) {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// refresh expired token
|
|
||||||
return await refreshAccessToken(token);
|
return await refreshAccessToken(token);
|
||||||
},
|
},
|
||||||
|
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
session.accessToken = token.accessToken;
|
session.accessToken = token.accessToken;
|
||||||
|
session.idToken = token.idToken;
|
||||||
|
session.roles = token.roles ?? [];
|
||||||
session.error = token.error;
|
session.error = token.error;
|
||||||
|
|
||||||
if (!token.accessToken) {
|
|
||||||
session.roles = [];
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = decode(token.accessToken);
|
|
||||||
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
|
||||||
|
|
||||||
const realmRoles = payload?.realm_access?.roles ?? [];
|
|
||||||
const clientRoles = payload?.resource_access?.[clientId]?.roles ?? [];
|
|
||||||
|
|
||||||
session.roles = [...new Set([...realmRoles, ...clientRoles])];
|
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// {
|
||||||
|
// provider: 'keycloak',
|
||||||
|
// type: 'oauth',
|
||||||
|
// providerAccountId: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
|
||||||
|
// access_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTY5MDIsImlhdCI6MTc3NzkxNjc4MiwiYXV0aF90aW1lIjoxNzc3OTEzNDc1LCJqdGkiOiJiZmRmMjc1ZC1jNWQ3LTRlZWQtYWJlYS04NDQ5YWRjY2E2NGMiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6WyJnaXRlYSIsImplbGx5ZmluIiwiYWNjb3VudCJdLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLWljZWJlcmciXX0sInJlc291cmNlX2FjY2VzcyI6eyJnaXRlYSI6eyJyb2xlcyI6WyJ0ZW1wbGF0ZSIsImljZWJlcmciLCJhZG1pbiIsImRlbGV0YW5nIiwidXNlciJdfSwiY2hhdC13ZWJzaXRlIjp7InJvbGVzIjpbImFkbWluIiwidXNlciJdfSwiamVsbHlmaW4iOnsicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIiwic2lkIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.X5PMv6-H21hdWvP768X4NDtRc_f5E1NALM1mSxM44-S8B8pQkP3ROSKZR2jmf828LOlLVfC-moODHTBgPkrwGGfkDpDXOtBzCo9-w_hu2S63OSSSg-ej3EPkT_7OocykSeq0V9VWllrdll7T6PiVuNX4dVstizVCweGJ7gtZXjDdi15VExfSjiddWk6mF-ZFX9B3TJFqjLfoXEaaEUrZJoqErKWbr8JlIYFPeaekYWxdScB_yoDJUPkruDShHbk5tGATr6OfkX_lsVQ5oEDDPQ_EHQU5P_r4ILEbXoAk96yJMTJo2yQLEgaeAX3RxBGXRlNYs3rV6cHYCw67mg0Now',
|
||||||
|
// expires_at: 1777916902,
|
||||||
|
// refresh_expires_in: 1800,
|
||||||
|
// refresh_token: 'eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2Nzc0ZGFiYi05NjAxLTQyOTYtYTkwYi0yOWFjYjVlM2U3N2UifQ.eyJleHAiOjE3Nzc5MTg1ODIsImlhdCI6MTc3NzkxNjc4MiwianRpIjoiODU3ZTZlMmEtZjI1MS00NTI3LWJjMDktYmY3MTM4ODE1NzQyIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmljZWJlcmcuYmxhY2svcmVhbG1zL2ljZWJlcmciLCJhdWQiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2Iiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSIsInNpZCI6IjJlMjRjYmY3LTE0MWItNDZlMC05NDdmLTlhZmNiZTFmNzViNiJ9.ydQzc6YWDdem_ubf4s9dw4Ys6hRnQSWBp8ZeweWZg7NqVCl7SvdxJ2XUgXNxMZ88cx5u_pE1bL6Kahsjvmq-wA',
|
||||||
|
// token_type: 'Bearer',
|
||||||
|
// id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTY5MDIsImlhdCI6MTc3NzkxNjc4MiwiYXV0aF90aW1lIjoxNzc3OTEzNDc1LCJqdGkiOiJhYjllYTliYi1lODkyLTQzNTctYmQ4NS0xYjk4NDg5ODliM2MiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6ImNoYXQtd2Vic2l0ZSIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IklEIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjJlMjRjYmY3LTE0MWItNDZlMC05NDdmLTlhZmNiZTFmNzViNiIsImF0X2hhc2giOiJFV1JkS0JYNmd0czZoNHFId0ViWG5nIiwic2lkIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.e2Jn0nbJ1qzNwnuSCAJMjHn6J3JOpJJ84ttne-XiZqtUln9KD3OaPikXH0Rzl-bKy6WxW7ZOLNdEiRUZgliUXxnc3j8zT5Fincm6cEZ3FOXIozlzdoo5i59gjZ9QqvbQ0794RvQJ-24J4C8xMdn5g5baOehf3gJL4pTRYfCECgijbQ-oAnu04YLT3OZN9GpCreU4VoIfgdebX2eiDuyTyECnfpfJuDBeAyn19fNp5TzY2GQHUUbhW2CxiTvHSBhYSw5Zld_csgTmTisWVJ7wTbAM_A7xjn9VjxCs5UnexzufGWL_O02Pi2XdTbloSSbHcB7rMAtSNE-o0fBHjdaH2g',
|
||||||
|
// 'not-before-policy': 0,
|
||||||
|
// session_state: '2e24cbf7-141b-46e0-947f-9afcbe1f75b6',
|
||||||
|
// scope: 'openid profile'
|
||||||
|
// }
|
||||||
|
|
||||||
|
// REFRESH
|
||||||
|
// {
|
||||||
|
// exp: 1777969679,
|
||||||
|
// iat: 1777967879,
|
||||||
|
// jti: 'cfb68e49-9383-46b7-af35-ad8cd357cd55',
|
||||||
|
// iss: 'https://auth.iceberg.black/realms/iceberg',
|
||||||
|
// aud: 'https://auth.iceberg.black/realms/iceberg',
|
||||||
|
// sub: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
|
||||||
|
// typ: 'Refresh',
|
||||||
|
// azp: 'chat-website',
|
||||||
|
// session_state: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
|
||||||
|
// scope: 'openid profile',
|
||||||
|
// sid: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1'
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ACCESS
|
||||||
|
// {
|
||||||
|
// exp: 1777968408,
|
||||||
|
// iat: 1777968288,
|
||||||
|
// auth_time: 1777967879,
|
||||||
|
// jti: 'ef46047d-c911-427d-bd41-872c92b0ffd3',
|
||||||
|
// iss: 'https://auth.iceberg.black/realms/iceberg',
|
||||||
|
// aud: [ 'gitea', 'jellyfin', 'account' ],
|
||||||
|
// sub: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
|
||||||
|
// typ: 'Bearer',
|
||||||
|
// azp: 'chat-website',
|
||||||
|
// session_state: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
|
||||||
|
// realm_access: {
|
||||||
|
// roles: [ 'offline_access', 'uma_authorization', 'default-roles-iceberg' ]
|
||||||
|
// },
|
||||||
|
// resource_access: {
|
||||||
|
// gitea: { roles: [Array] },
|
||||||
|
// 'chat-website': { roles: [Array] },
|
||||||
|
// jellyfin: { roles: [Array] },
|
||||||
|
// account: { roles: [Array] }
|
||||||
|
// },
|
||||||
|
// scope: 'openid profile',
|
||||||
|
// sid: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
|
||||||
|
// name: ' ',
|
||||||
|
// preferred_username: '',
|
||||||
|
// given_name: '',
|
||||||
|
// family_name: ''
|
||||||
|
// }
|
||||||
|
|
||||||
|
// TOKEN OF REFRESH
|
||||||
|
// {
|
||||||
|
// access_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTE1OTEsImlhdCI6MTc3NzkxMTQ3MSwiYXV0aF90aW1lIjoxNzc3OTA5MjAwLCJqdGkiOiJmNTI4ZWEzOC0zOTM0LTQ5ZTEtOGI0ZC1lNjU4OWY5ZTJjZDkiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6WyJnaXRlYSIsImplbGx5ZmluIiwiYWNjb3VudCJdLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLWljZWJlcmciXX0sInJlc291cmNlX2FjY2VzcyI6eyJnaXRlYSI6eyJyb2xlcyI6WyJ0ZW1wbGF0ZSIsImljZWJlcmciLCJhZG1pbiIsImRlbGV0YW5nIiwidXNlciJdfSwiY2hhdC13ZWJzaXRlIjp7InJvbGVzIjpbImFkbWluIiwidXNlciJdfSwiamVsbHlmaW4iOnsicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIG9mZmxpbmVfYWNjZXNzIiwic2lkIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.NOol8mmBqxsvKwUFkWDzJE2GxmjU5DsIc-WX9BbNycFJtYcpcKLDeZfNMKlKid-JYnOd8pBt3phl_2OJtkYZPD_GVdq1QFfHkDCf7zEJPEf11lJd7pjT2S-adNzppentz2EJyW-Eb3m2Bqoeq0gaJbMi3gaEzfwgQWFTXxoNi5N7RHGVLoa9xtS4tU0p0IXKUOD4XV2aScSedv0qtTLog2x54ZyXw_mcH6UQNrNYU27Gf1fby_i2fgU-ZyD3CKPMD-kPlq2dwePVxh-z6tDGAOfwNw2gsHgScZMyQ4pO7UIkEs0IuxW-O-96FgRMhttKWE4F3sqcMbeQagw4SKs_Bg',
|
||||||
|
// expires_in: 120,
|
||||||
|
// refresh_expires_in: 0,
|
||||||
|
// refresh_token: 'eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2Nzc0ZGFiYi05NjAxLTQyOTYtYTkwYi0yOWFjYjVlM2U3N2UifQ.eyJpYXQiOjE3Nzc5MTE0NzEsImp0aSI6ImE5MzQ2ZWJhLTk0NTMtNDZmZS1iMzk2LTIxMDFkYjM3YjA3MyIsImlzcyI6Imh0dHBzOi8vYXV0aC5pY2ViZXJnLmJsYWNrL3JlYWxtcy9pY2ViZXJnIiwiYXVkIjoiaHR0cHM6Ly9hdXRoLmljZWJlcmcuYmxhY2svcmVhbG1zL2ljZWJlcmciLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJPZmZsaW5lIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjY4YWE4ZDEyLTRlMGUtNDdjNi04OTVmLTMzODcwNzExNTJmNCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgb2ZmbGluZV9hY2Nlc3MiLCJzaWQiOiI2OGFhOGQxMi00ZTBlLTQ3YzYtODk1Zi0zMzg3MDcxMTUyZjQifQ.zd83fs3rwAdO7KodcGRZoW-COaLISWmSfh3vK6NNQp3zB4mypI09lGieEoTO_XOUWzNgmtt9YmbEgGgXk-uW7w',
|
||||||
|
// token_type: 'Bearer',
|
||||||
|
// id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTE1OTEsImlhdCI6MTc3NzkxMTQ3MSwiYXV0aF90aW1lIjoxNzc3OTA5MjAwLCJqdGkiOiI3MGEzOTc1MS04NzJjLTRmMzUtODJjNC01OTI5ODFkN2Q2NzgiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6ImNoYXQtd2Vic2l0ZSIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IklEIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjY4YWE4ZDEyLTRlMGUtNDdjNi04OTVmLTMzODcwNzExNTJmNCIsImF0X2hhc2giOiI3OGcyYUFsNEdqVXFxS0loRmhDTDNRIiwic2lkIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.PdsFWknqvdNKv68SWg9hau9ekFWHdcyA1HRJvwS55H0-ijUlMR2iAhjQYqb44aboko90wtIF1LLIsd2JQMY3gTgcU0qAsf5LdPMX_zRrXoACMYR26rTWX64OmgBX9oWv2rSduGIngR8k6xuugXNBXSXiMwYbHeMSJ7H79a2tyhxqjmnuCxtnWfyQg8yJGIC9e92dDimM5gq9hvNbVQXesjWS3pGCnq0EhVw_90sTMOwk_4CUuPy_0ZgVUNJklyJzxYqMibYto_X2Mb2g2JT2gUZAym78URYhLiVg1A99vh9JTx4NC1vHktlIsEGZLySz52FvtVo7P-C3EqdLKMrSKw',
|
||||||
|
// 'not-before-policy': 0,
|
||||||
|
// session_state: '68aa8d12-4e0e-47c6-895f-3387071152f4',
|
||||||
|
// scope: 'openid profile offline_access'
|
||||||
|
// }
|
||||||
@@ -6,7 +6,6 @@ import { getToken } from 'next-auth/jwt';
|
|||||||
|
|
||||||
const protectedRoutes: Record<string, string[]> = {
|
const protectedRoutes: Record<string, string[]> = {
|
||||||
'/example': ['ad'],
|
'/example': ['ad'],
|
||||||
'/dashboard': [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const intlMiddleware = createIntlMiddleware(routing);
|
const intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|||||||
@@ -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