import { useEffect, useRef, useState } from 'react'; import { Nav } from '~/components/nav'; const API_URL = import.meta.env.VITE_API_URL ?? 'https://api.t4t-gateway.com'; const SESSIONS_KEY = 't4t:chatSessions'; const ACTIVE_SESSION_KEY = 't4t:activeChatId'; type Model = { id: string; object: 'model'; created: number; owned_by: string }; type Message = { role: 'user' | 'assistant' | 'system'; content: string }; type ChoiceMessage = { role: 'assistant'; content: string }; type Completion = { id: string; choices: { index: number; message: ChoiceMessage; finish_reason: string }[]; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; }; type ChatSession = { id: string; title: string; model: string; messages: Message[]; updatedAt: number; }; export function meta() { return [{ title: 'Chat · t4t Gateway' }]; } async function api(path: string, init: RequestInit = {}): Promise { const token = typeof window !== 'undefined' ? localStorage.getItem('jwt') : null; if (!token) throw new Error('Not authenticated'); const res = await fetch(`${API_URL}${path}`, { ...init, headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}`, ...(init.body ? { 'Content-Type': 'application/json' } : {}), }, }); if (res.status === 401) { localStorage.removeItem('jwt'); window.location.href = '/login'; throw new Error('Unauthorized'); } const body = await res.json(); if (!res.ok) throw new Error(body?.message ?? body?.error ?? `Request failed (${res.status})`); return body as T; } function newSessionId(): string { return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; } function titleFor(messages: Message[]): string { const firstUser = messages.find((m) => m.role === 'user'); if (!firstUser) return 'New chat'; const text = firstUser.content.trim().replace(/\s+/g, ' '); if (!text) return 'New chat'; return text.length > 40 ? `${text.slice(0, 40)}…` : text; } function loadSessions(): ChatSession[] { if (typeof window === 'undefined') return []; try { const raw = localStorage.getItem(SESSIONS_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function saveSessions(sessions: ChatSession[]) { if (typeof window === 'undefined') return; localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); } export default function Chat() { const [models, setModels] = useState([]); const [model, setModel] = useState(''); const [sessions, setSessions] = useState([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [error, setError] = useState(null); const [loadingModels, setLoadingModels] = useState(true); const scrollRef = useRef(null); useEffect(() => { if (typeof window === 'undefined') return; if (!localStorage.getItem('jwt')) { window.location.href = '/login'; return; } const stored = loadSessions(); const storedActiveId = localStorage.getItem(ACTIVE_SESSION_KEY); const initial = stored.find((s) => s.id === storedActiveId) ?? [...stored].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; if (initial) { setSessions(stored); setActiveId(initial.id); setMessages(initial.messages); setModel(initial.model); localStorage.setItem(ACTIVE_SESSION_KEY, initial.id); } else { const fresh: ChatSession = { id: newSessionId(), title: 'New chat', model: '', messages: [], updatedAt: Date.now(), }; setSessions([fresh]); setActiveId(fresh.id); saveSessions([fresh]); localStorage.setItem(ACTIVE_SESSION_KEY, fresh.id); } (async () => { try { const list = await api<{ data: Model[] }>('/v1/models'); setModels(list.data); setModel((current) => current || (list.data.length > 0 ? list.data[0].id : '')); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoadingModels(false); } })(); }, []); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages]); function persist(id: string, mdl: string, msgs: Message[]) { setSessions((prev) => { const updated = prev.map((s) => s.id === id ? { ...s, model: mdl, messages: msgs, title: titleFor(msgs), updatedAt: Date.now() } : s ); saveSessions(updated); return updated; }); } function selectSession(id: string) { const session = sessions.find((s) => s.id === id); if (!session) return; setActiveId(id); setMessages(session.messages); setModel(session.model || model); setError(null); localStorage.setItem(ACTIVE_SESSION_KEY, id); } function newChat() { const fresh: ChatSession = { id: newSessionId(), title: 'New chat', model, messages: [], updatedAt: Date.now(), }; setSessions((prev) => { const updated = [fresh, ...prev]; saveSessions(updated); return updated; }); setActiveId(fresh.id); setMessages([]); setError(null); localStorage.setItem(ACTIVE_SESSION_KEY, fresh.id); } function deleteSession(id: string) { setSessions((prev) => { const updated = prev.filter((s) => s.id !== id); saveSessions(updated); if (id === activeId) { const next = [...updated].sort((a, b) => b.updatedAt - a.updatedAt)[0]; if (next) { setActiveId(next.id); setMessages(next.messages); setModel(next.model || model); localStorage.setItem(ACTIVE_SESSION_KEY, next.id); } else { const fresh: ChatSession = { id: newSessionId(), title: 'New chat', model, messages: [], updatedAt: Date.now(), }; updated.push(fresh); saveSessions(updated); setActiveId(fresh.id); setMessages([]); localStorage.setItem(ACTIVE_SESSION_KEY, fresh.id); } } return updated; }); } async function send(e: React.FormEvent) { e.preventDefault(); if (!input.trim() || !model || sending || !activeId) return; setError(null); const userMsg: Message = { role: 'user', content: input }; const next = [...messages, userMsg]; setMessages(next); persist(activeId, model, next); setInput(''); setSending(true); try { const completion = await api('/v1/chat/completions', { method: 'POST', body: JSON.stringify({ model, messages: next }), }); const reply = completion.choices[0]?.message; if (reply) { const withReply = [...next, { role: reply.role, content: reply.content }]; setMessages(withReply); persist(activeId, model, withReply); } else { throw new Error('No reply from model'); } } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setSending(false); } } const sortedSessions = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt); return (
); }