diff --git a/app/routes.ts b/app/routes.ts index 26f5acf..9e3d0c5 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -6,4 +6,5 @@ export default [ route('verify', 'routes/verify.tsx'), route('login', 'routes/login.tsx'), route('account', 'routes/account.tsx'), + route('chat', 'routes/chat.tsx'), ] satisfies RouteConfig; diff --git a/app/routes/chat.tsx b/app/routes/chat.tsx new file mode 100644 index 0000000..55bdbac --- /dev/null +++ b/app/routes/chat.tsx @@ -0,0 +1,198 @@ +import { useEffect, useRef, useState } from 'react'; + +const API_URL = import.meta.env.VITE_API_URL ?? 'https://api.t4t-gateway.com'; + +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 }; +}; + +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; +} + +export default function Chat() { + const [models, setModels] = useState([]); + const [model, setModel] = useState(''); + 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; + } + (async () => { + try { + const list = await api<{ data: Model[] }>('/v1/models'); + setModels(list.data); + if (list.data.length > 0) setModel(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]); + + async function send(e: React.FormEvent) { + e.preventDefault(); + if (!input.trim() || !model || sending) return; + setError(null); + const userMsg: Message = { role: 'user', content: input }; + const next = [...messages, userMsg]; + setMessages(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) { + setMessages([...next, { role: reply.role, content: reply.content }]); + } else { + throw new Error('No reply from model'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setSending(false); + } + } + + return ( +
+
+
+
+ + ← Account + + | +

Chat

+
+ +
+
+ +
+ {messages.length === 0 && ( +

+ Pick a model and send a message to start. +

+ )} +
    + {messages.map((m, i) => ( +
  • +
    + {m.content} +
    +
  • + ))} + {sending && ( +
  • +
    + Thinking… +
    +
  • + )} +
+
+ + {error && ( +

+ {error} +

+ )} + +
+
+ setInput(e.target.value)} + disabled={sending || !model} + className='flex-1 rounded-md border border-neutral-300 px-3 py-2 text-sm shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900 disabled:opacity-50' + /> + +
+
+
+ ); +}