mirror of
https://github.com/karpathy/nanochat.git
synced 2026-05-09 01:10:10 +00:00
Build services/frontend/ replacing the legacy nanochat/ui.html single-file UI. Landing, login, and chat pages ported with full design system: Devanagari + Great Vibes hero, samosa/chai/toran SVG animations, gold/cream palette. - App Router pages: / (hero + floating illustrations), /login (split-screen OAuth with mandala motif), /chat (260px collapsible sidebar, suggestion chips, markdown + code-copy, auto-expanding input, slash commands) - SSE streaming via useSSE hook and /api/chat/stream BFF route (proxies to CHAT_API_URL when set, falls back to mock echo for local dev) - NextAuth.js v5 with Google + GitHub providers; middleware gates /chat/* - Zustand store with localStorage persistence for conversations/settings - Tailwind theme carries all ui.html tokens + keyframes (pendulum, float, wobble, steamFloat, steamType); SVG assets componentized under components/svg - Multi-stage node:20-alpine Dockerfile with Next standalone output Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
'use client';
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import type { Conversation, Message, ModelOption } from '@/types/chat';
|
|
|
|
const uid = () => Math.random().toString(36).slice(2, 10);
|
|
|
|
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)' },
|
|
];
|
|
|
|
interface ChatState {
|
|
conversations: Conversation[];
|
|
currentConversationId: string | null;
|
|
model: string;
|
|
temperature: number;
|
|
topK: number;
|
|
sidebarOpen: boolean;
|
|
|
|
setModel: (m: string) => void;
|
|
setTemperature: (t: number) => void;
|
|
setTopK: (k: number) => void;
|
|
toggleSidebar: () => void;
|
|
|
|
newConversation: () => string;
|
|
selectConversation: (id: string) => void;
|
|
deleteConversation: (id: string) => void;
|
|
appendMessage: (conversationId: string, message: Omit<Message, 'id' | 'createdAt'>) => string;
|
|
updateMessage: (conversationId: string, messageId: string, content: string) => void;
|
|
setConversationTitle: (id: string, title: string) => void;
|
|
hydrateMockConversations: () => void;
|
|
}
|
|
|
|
const now = () => Date.now();
|
|
|
|
const MOCK_CONVERSATIONS: Conversation[] = [
|
|
{
|
|
id: 'mock-1',
|
|
title: 'Why is samosa triangular?',
|
|
messages: [
|
|
{ id: 'm1', role: 'user', content: 'Why is samosa triangular?', createdAt: now() - 1000 * 60 * 30 },
|
|
{ id: 'm2', role: 'assistant', content: 'The triangular shape likely evolved for easy frying and portability — three edges crisp evenly and the pocket holds filling tightly.', createdAt: now() - 1000 * 60 * 29 },
|
|
],
|
|
createdAt: now() - 1000 * 60 * 30,
|
|
updatedAt: now() - 1000 * 60 * 29,
|
|
},
|
|
{
|
|
id: 'mock-2',
|
|
title: 'Chai masala recipe',
|
|
messages: [
|
|
{ id: 'm1', role: 'user', content: 'Classic masala chai recipe please', createdAt: now() - 1000 * 60 * 60 * 26 },
|
|
],
|
|
createdAt: now() - 1000 * 60 * 60 * 26,
|
|
updatedAt: now() - 1000 * 60 * 60 * 26,
|
|
},
|
|
{
|
|
id: 'mock-3',
|
|
title: 'Explain transformers simply',
|
|
messages: [],
|
|
createdAt: now() - 1000 * 60 * 60 * 24 * 4,
|
|
updatedAt: now() - 1000 * 60 * 60 * 24 * 4,
|
|
},
|
|
{
|
|
id: 'mock-4',
|
|
title: 'Monsoon pakora tips',
|
|
messages: [],
|
|
createdAt: now() - 1000 * 60 * 60 * 24 * 15,
|
|
updatedAt: now() - 1000 * 60 * 60 * 24 * 15,
|
|
},
|
|
];
|
|
|
|
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 })),
|
|
|
|
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;
|
|
},
|
|
|
|
selectConversation: (id) => set({ currentConversationId: id }),
|
|
|
|
deleteConversation: (id) =>
|
|
set((s) => {
|
|
const rest = s.conversations.filter((c) => c.id !== id);
|
|
return {
|
|
conversations: rest,
|
|
currentConversationId: s.currentConversationId === id ? rest[0]?.id ?? null : s.currentConversationId,
|
|
};
|
|
}),
|
|
|
|
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)),
|
|
})),
|
|
|
|
hydrateMockConversations: () => {
|
|
const { conversations } = get();
|
|
if (conversations.length === 0) {
|
|
set({ conversations: MOCK_CONVERSATIONS, currentConversationId: MOCK_CONVERSATIONS[0].id });
|
|
}
|
|
},
|
|
}),
|
|
{
|
|
name: 'samosachaat-chat',
|
|
partialize: (s) => ({
|
|
conversations: s.conversations,
|
|
currentConversationId: s.currentConversationId,
|
|
model: s.model,
|
|
temperature: s.temperature,
|
|
topK: s.topK,
|
|
}),
|
|
},
|
|
),
|
|
);
|
|
|
|
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;
|
|
}
|