mirror of
https://github.com/karpathy/nanochat.git
synced 2026-05-09 01:10:10 +00:00
Remove NextAuth and replace with token-based auth against the backend
auth service (OAuth + JWT). The frontend now redirects login to
/api/auth/google and /api/auth/github (proxied by nginx to the auth
service), captures the JWT from the redirect query param, and uses it
for all API calls.
Key changes:
- Remove next-auth dependency and all NextAuth config/routes
- Add lib/auth-client.ts (JWT token storage + auth headers)
- Add hooks/useAuth.ts (client-side auth state + token capture)
- Rewrite middleware.ts to pass-through (client-side auth only)
- Login page uses plain <a> links to /api/auth/{provider}
- Chat page captures access_token from OAuth redirect
- Zustand store fetches conversations from real chat-api via JWT
- API routes proxy /api/conversations/* to chat-api with auth
- chat/stream route supports conversationId + auth header forwarding
- useSSE hook accepts auth headers for authenticated streaming
- Sidebar loads conversations from API, supports delete
- Landing page (Hero, LandingNav) uses useAuth instead of useSession
- Add .env.production.example and scripts/generate-jwt-keys.sh
Mock echo fallback preserved when CHAT_API_URL is not set.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
267 lines
8.9 KiB
TypeScript
267 lines
8.9 KiB
TypeScript
'use client';
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { authHeaders } from '@/lib/auth-client';
|
|
import type { Conversation, Message, ModelOption } from '@/types/chat';
|
|
|
|
const uid = () => Math.random().toString(36).slice(2, 10);
|
|
const now = () => Date.now();
|
|
|
|
export const MODEL_OPTIONS: ModelOption[] = [
|
|
{ id: 'nanochat-base', label: 'nanochat · base', description: 'Default cloud model' },
|
|
{ id: 'nanochat-chat', label: 'nanochat · chat', description: 'Instruction-tuned' },
|
|
{ id: 'nanochat-local', label: 'nanochat · local (WebGPU)', description: 'Browser GPU (experimental)' },
|
|
];
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* State shape */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface ChatState {
|
|
/* data */
|
|
conversations: Conversation[];
|
|
currentConversationId: string | null;
|
|
|
|
/* settings (persisted in localStorage) */
|
|
model: string;
|
|
temperature: number;
|
|
topK: number;
|
|
sidebarOpen: boolean;
|
|
|
|
/* setting mutators */
|
|
setModel: (m: string) => void;
|
|
setTemperature: (t: number) => void;
|
|
setTopK: (k: number) => void;
|
|
toggleSidebar: () => void;
|
|
|
|
/* API-backed actions */
|
|
fetchConversations: () => Promise<void>;
|
|
fetchMessages: (conversationId: string) => Promise<void>;
|
|
createConversation: (title?: string) => Promise<string | null>;
|
|
deleteConversation: (id: string) => Promise<void>;
|
|
selectConversation: (id: string) => void;
|
|
|
|
/* local-only helpers (optimistic UI) */
|
|
newConversation: () => string;
|
|
appendMessage: (conversationId: string, message: Omit<Message, 'id' | 'createdAt'>) => string;
|
|
updateMessage: (conversationId: string, messageId: string, content: string) => void;
|
|
setConversationTitle: (id: string, title: string) => void;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Store */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export const useChatStore = create<ChatState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
conversations: [],
|
|
currentConversationId: null,
|
|
model: 'nanochat-base',
|
|
temperature: 0.8,
|
|
topK: 50,
|
|
sidebarOpen: true,
|
|
|
|
setModel: (m) => set({ model: m }),
|
|
setTemperature: (t) => set({ temperature: t }),
|
|
setTopK: (k) => set({ topK: k }),
|
|
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
|
|
|
/* ---- API actions ---- */
|
|
|
|
fetchConversations: async () => {
|
|
try {
|
|
const res = await fetch('/api/conversations', {
|
|
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
});
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const list: Conversation[] = (data.conversations ?? []).map(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(c: any) => ({
|
|
id: c.id,
|
|
title: c.title ?? 'New chat',
|
|
messages: c.messages ?? [],
|
|
createdAt: c.createdAt ? new Date(c.createdAt).getTime() : now(),
|
|
updatedAt: c.updatedAt ? new Date(c.updatedAt).getTime() : now(),
|
|
}),
|
|
);
|
|
set({ conversations: list });
|
|
} catch (err) {
|
|
console.error('[chatStore] fetchConversations error:', err);
|
|
}
|
|
},
|
|
|
|
fetchMessages: async (conversationId) => {
|
|
try {
|
|
const res = await fetch(`/api/conversations/${conversationId}`, {
|
|
headers: { ...authHeaders() },
|
|
});
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const messages: Message[] = (data.messages ?? []).map((m: any) => ({
|
|
id: m.id ?? uid(),
|
|
role: m.role,
|
|
content: m.content,
|
|
createdAt: m.createdAt ? new Date(m.createdAt).getTime() : now(),
|
|
}));
|
|
set((s) => ({
|
|
conversations: s.conversations.map((c) =>
|
|
c.id === conversationId ? { ...c, messages } : c,
|
|
),
|
|
}));
|
|
} catch (err) {
|
|
console.error('[chatStore] fetchMessages error:', err);
|
|
}
|
|
},
|
|
|
|
createConversation: async (title) => {
|
|
try {
|
|
const res = await fetch('/api/conversations', {
|
|
method: 'POST',
|
|
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: title ?? 'New chat' }),
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
const conv: Conversation = {
|
|
id: data.id,
|
|
title: data.title ?? title ?? 'New chat',
|
|
messages: [],
|
|
createdAt: data.createdAt ? new Date(data.createdAt).getTime() : now(),
|
|
updatedAt: data.updatedAt ? new Date(data.updatedAt).getTime() : now(),
|
|
};
|
|
set((s) => ({
|
|
conversations: [conv, ...s.conversations],
|
|
currentConversationId: conv.id,
|
|
}));
|
|
return conv.id;
|
|
} catch (err) {
|
|
console.error('[chatStore] createConversation error:', err);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
deleteConversation: async (id) => {
|
|
try {
|
|
await fetch(`/api/conversations/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { ...authHeaders() },
|
|
});
|
|
} catch (err) {
|
|
console.error('[chatStore] deleteConversation error:', err);
|
|
}
|
|
set((s) => {
|
|
const rest = s.conversations.filter((c) => c.id !== id);
|
|
return {
|
|
conversations: rest,
|
|
currentConversationId:
|
|
s.currentConversationId === id ? rest[0]?.id ?? null : s.currentConversationId,
|
|
};
|
|
});
|
|
},
|
|
|
|
selectConversation: (id) => {
|
|
set({ currentConversationId: id });
|
|
// Fetch latest messages for the selected conversation
|
|
get().fetchMessages(id);
|
|
},
|
|
|
|
/* ---- local-only helpers (for optimistic UI & mock mode) ---- */
|
|
|
|
newConversation: () => {
|
|
const id = uid();
|
|
const conv: Conversation = {
|
|
id,
|
|
title: 'New chat',
|
|
messages: [],
|
|
createdAt: now(),
|
|
updatedAt: now(),
|
|
};
|
|
set((s) => ({ conversations: [conv, ...s.conversations], currentConversationId: id }));
|
|
return id;
|
|
},
|
|
|
|
appendMessage: (conversationId, message) => {
|
|
const id = uid();
|
|
set((s) => ({
|
|
conversations: s.conversations.map((c) =>
|
|
c.id === conversationId
|
|
? {
|
|
...c,
|
|
messages: [...c.messages, { ...message, id, createdAt: now() }],
|
|
updatedAt: now(),
|
|
title:
|
|
c.title === 'New chat' && message.role === 'user'
|
|
? message.content.slice(0, 48)
|
|
: c.title,
|
|
}
|
|
: c,
|
|
),
|
|
}));
|
|
return id;
|
|
},
|
|
|
|
updateMessage: (conversationId, messageId, content) =>
|
|
set((s) => ({
|
|
conversations: s.conversations.map((c) =>
|
|
c.id === conversationId
|
|
? {
|
|
...c,
|
|
messages: c.messages.map((m) =>
|
|
m.id === messageId ? { ...m, content } : m,
|
|
),
|
|
updatedAt: now(),
|
|
}
|
|
: c,
|
|
),
|
|
})),
|
|
|
|
setConversationTitle: (id, title) =>
|
|
set((s) => ({
|
|
conversations: s.conversations.map((c) => (c.id === id ? { ...c, title } : c)),
|
|
})),
|
|
}),
|
|
{
|
|
name: 'samosachaat-settings',
|
|
// Only persist user preferences — conversations live in the DB
|
|
partialize: (s) => ({
|
|
model: s.model,
|
|
temperature: s.temperature,
|
|
topK: s.topK,
|
|
sidebarOpen: s.sidebarOpen,
|
|
}),
|
|
},
|
|
),
|
|
);
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Grouping helper (unchanged) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function groupConversations(conversations: Conversation[]) {
|
|
const day = 1000 * 60 * 60 * 24;
|
|
const t = now();
|
|
const startOfToday = new Date(t).setHours(0, 0, 0, 0);
|
|
const startOfYesterday = startOfToday - day;
|
|
const startOfWeek = startOfToday - day * 6;
|
|
|
|
const buckets: Record<string, Conversation[]> = {
|
|
Today: [],
|
|
Yesterday: [],
|
|
'Last 7 days': [],
|
|
Older: [],
|
|
};
|
|
|
|
for (const c of [...conversations].sort((a, b) => b.updatedAt - a.updatedAt)) {
|
|
if (c.updatedAt >= startOfToday) buckets.Today.push(c);
|
|
else if (c.updatedAt >= startOfYesterday) buckets.Yesterday.push(c);
|
|
else if (c.updatedAt >= startOfWeek) buckets['Last 7 days'].push(c);
|
|
else buckets.Older.push(c);
|
|
}
|
|
|
|
return buckets;
|
|
}
|