Merge pull request #15 from manmohan659/feat/frontend-service

feat(frontend): Next.js 14 frontend service for samosaChaat (Workstream A, #2)
This commit is contained in:
Manmohan 2026-04-16 14:27:18 -04:00 committed by GitHub
commit 9bd0c907cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 10078 additions and 3 deletions

View File

@ -0,0 +1,11 @@
node_modules
.next
.git
.env
.env*.local
Dockerfile
.dockerignore
README.md
npm-debug.log*
coverage
.DS_Store

View File

@ -0,0 +1,16 @@
# Public URL of the frontend
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Internal service URLs (server-side only, BFF pattern)
AUTH_SERVICE_URL=http://localhost:4000
CHAT_API_URL=http://localhost:8000
# NextAuth.js v5 — generate with: openssl rand -base64 32
NEXTAUTH_SECRET=change-me-in-production
AUTH_TRUST_HOST=true
# OAuth providers
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

13
services/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
node_modules/
.next/
out/
.env
.env.local
.env*.local
*.log
.DS_Store
.vercel
next-env.d.ts.bak
coverage/
.turbo/
tsconfig.tsbuildinfo

View File

@ -1,9 +1,36 @@
FROM python:3.12-slim
# syntax=docker/dockerfile:1.6
# Multi-stage Dockerfile for samosaChaat Next.js frontend.
# ---------- deps ----------
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY index.html /app/index.html
# ---------- builder ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---------- runner ----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["python", "-m", "http.server", "3000", "--bind", "0.0.0.0"]
CMD ["node", "server.js"]

View File

@ -0,0 +1,70 @@
# samosaChaat — Frontend Service
Next.js 14 (App Router) application that replaces the legacy `nanochat/ui.html`. It blends Sarvam.ai's clean split-screen layout with samosaChaat's warm desi personality — samosa and chai illustrations, lemon-mirchi toran, gold + cream palette.
## Pages
- `/` Landing — Devanagari + Great Vibes calligraphy, animated samosa (float), chai kettle (wobble + steam), toran (pendulum), ambient doodles, CTA.
- `/login` Sign-in — left: architectural/mandala motif with saffron-cream gradient; right: Google + GitHub OAuth, disabled email input.
- `/chat` Chat — collapsible 260px sidebar (grouped history, model selector, user avatar, logout), main area with empty state + suggestion chips, markdown rendering with code-copy, auto-expanding textarea, slash commands (`/temperature`, `/topk`, `/clear`, `/help`), SSE streaming with steam typing indicator.
## Tech
- Next.js 14 (App Router, standalone output)
- Tailwind CSS (theme tokens carried from `ui.html`)
- Zustand (persisted conversations/settings)
- NextAuth.js v5 (Google + GitHub)
- Framer Motion (hero transitions)
- Lucide React (icons)
- react-markdown + rehype-highlight (assistant rendering)
## Environment
Copy `.env.example``.env.local` and fill in:
| Var | Purpose |
| --- | --- |
| `NEXT_PUBLIC_APP_URL` | Public URL (defaults to `http://localhost:3000`) |
| `AUTH_SERVICE_URL` | Auth microservice (BFF only; reserved for future) |
| `CHAT_API_URL` | Upstream chat service. **If unset the frontend serves mock echo responses** — perfect for local dev. |
| `NEXTAUTH_SECRET` | `openssl rand -base64 32` |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google OAuth (optional in dev) |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | GitHub OAuth (optional in dev) |
Middleware protects `/chat/*` — unauthenticated users are redirected to `/login?callbackUrl=/chat`.
## Run locally
```bash
cd services/frontend
npm install
cp .env.example .env.local # then edit
npm run dev
# → http://localhost:3000
```
Without any OAuth keys the `/login` buttons will show the NextAuth error page; the rest of the UI works via the mocked API routes.
## API routes (BFF pattern)
All client calls hit Next.js routes — the frontend never talks to the chat backend directly.
- `GET /api/health` — service info + upstream config flags
- `GET /api/conversations` — mocked conversation list
- `POST /api/chat/stream` — proxies to `CHAT_API_URL/chat/completions` if set, otherwise streams a mock echo. Emits SSE: `data: {"token": "...", "gpu": 0}\n\n` and terminates with `data: {"done": true}\n\n`.
## Docker
```bash
docker build -t samosachaat-frontend .
docker run --rm -p 3000:3000 \
-e NEXTAUTH_SECRET=... \
-e CHAT_API_URL=http://host.docker.internal:8000 \
samosachaat-frontend
```
The image is based on `node:20-alpine`, uses Next.js `output: standalone`, and runs as a non-root user.
## Porting notes
All SVG assets (samosa, chai kettle, toran, steam, doodles, logo) are componentized under `components/svg/`. CSS keyframes (`pendulum`, `float`, `wobble`, `steamFloat`, `steamType`) moved into `tailwind.config.ts` — reference via the `animate-*` utilities. The original single-file UI lives at `nanochat/ui.html` for reference.

View File

@ -0,0 +1,3 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

View File

@ -0,0 +1,95 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface StreamBody {
messages: Array<{ role: string; content: string }>;
model?: string;
temperature?: number;
topK?: number;
}
const encoder = new TextEncoder();
function sseEvent(data: Record<string, unknown>) {
return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
}
async function proxyUpstream(body: StreamBody, upstreamUrl: string) {
const upstream = await fetch(upstreamUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: body.messages,
temperature: body.temperature ?? 0.8,
top_k: body.topK ?? 50,
max_tokens: 512,
model: body.model,
}),
});
if (!upstream.ok || !upstream.body) {
throw new Error(`upstream HTTP ${upstream.status}`);
}
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}
function mockEcho(body: StreamBody): Response {
const last = body.messages[body.messages.length - 1]?.content ?? '';
const greetings = [
'Namaste! ',
"Here's what I can offer for that question: ",
"Let's think about it together. ",
];
const greeting = greetings[Math.floor(Math.random() * greetings.length)];
const echo = last.trim() ? `You asked: "${last.trim()}".` : 'I am listening.';
const full = `${greeting}${echo}\n\nThis is a mock response from the samosaChaat frontend — once the chat service is wired, real streaming tokens will land here.`;
const stream = new ReadableStream({
async start(controller) {
const words = full.split(/(\s+)/);
for (const w of words) {
controller.enqueue(sseEvent({ token: w, gpu: 0 }));
await new Promise((r) => setTimeout(r, 25));
}
controller.enqueue(sseEvent({ done: true }));
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}
export async function POST(req: NextRequest) {
let body: StreamBody;
try {
body = (await req.json()) as StreamBody;
} catch {
return new Response('Invalid JSON', { status: 400 });
}
const upstream = process.env.CHAT_API_URL;
if (upstream) {
try {
return await proxyUpstream(body, `${upstream.replace(/\/$/, '')}/chat/completions`);
} catch (err) {
console.warn('[chat/stream] upstream failed, falling back to mock:', err);
}
}
return mockEcho(body);
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
const day = 1000 * 60 * 60 * 24;
const now = () => Date.now();
export async function GET() {
return NextResponse.json({
conversations: [
{
id: 'mock-1',
title: 'Why is samosa triangular?',
updatedAt: now() - 1000 * 60 * 30,
},
{
id: 'mock-2',
title: 'Chai masala recipe',
updatedAt: now() - day,
},
{
id: 'mock-3',
title: 'Explain transformers simply',
updatedAt: now() - day * 4,
},
{
id: 'mock-4',
title: 'Monsoon pakora tips',
updatedAt: now() - day * 15,
},
],
});
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'ok',
service: 'samosachaat-frontend',
chatApiConfigured: Boolean(process.env.CHAT_API_URL),
authServiceConfigured: Boolean(process.env.AUTH_SERVICE_URL),
});
}

View File

@ -0,0 +1,13 @@
import Sidebar from '@/components/chat/Sidebar';
import ChatWindow from '@/components/chat/ChatWindow';
export const metadata = { title: 'Chat — samosaChaat' };
export default function ChatPage() {
return (
<main className="flex h-dvh overflow-hidden">
<Sidebar />
<ChatWindow />
</main>
);
}

View File

@ -0,0 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
--gold: #e8a838;
--brown: #8b4d0a;
--cream: #fff8e7;
--green-chutney: #2d8a4e;
--red-chutney: #c0392b;
--warm-grey: #d4c4a0;
--light-cream: #fffdf5;
}
html, body { height: 100%; }
body {
@apply font-sans bg-white text-gray-900;
min-height: 100dvh;
overflow-x: hidden;
}
/* Markdown prose tweaks inside message bubbles */
.markdown-body > *:first-child { margin-top: 0 !important; }
.markdown-body > *:last-child { margin-bottom: 0 !important; }
.markdown-body pre {
@apply bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto my-2 text-sm;
}
.markdown-body code:not(pre code) {
@apply px-1 py-0.5 rounded bg-cream-light border border-cream-border text-brown text-[0.9em];
}
.markdown-body p { @apply my-2 leading-relaxed; }
.markdown-body ul { @apply list-disc pl-6 my-2; }
.markdown-body ol { @apply list-decimal pl-6 my-2; }
.markdown-body h1 { @apply text-xl font-bold mt-4 mb-2; }
.markdown-body h2 { @apply text-lg font-bold mt-3 mb-2; }
.markdown-body h3 { @apply text-base font-bold mt-2 mb-1; }
.markdown-body blockquote {
@apply border-l-4 border-cream-border pl-4 italic text-brown-light my-2;
}
.markdown-body a { @apply text-chutney-green underline hover:text-gold; }
/* Scrollbar tuning */
.nice-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.nice-scrollbar::-webkit-scrollbar-thumb { background: #e0d5c0; border-radius: 3px; }
.nice-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--warm-grey); }
/* Highlight.js minimal tweaks */
.hljs { background: transparent; }

View File

@ -0,0 +1,54 @@
import type { Metadata, Viewport } from 'next';
import { Baloo_2, Great_Vibes, Caveat, Inter } from 'next/font/google';
import SessionBoundary from '@/components/SessionBoundary';
import './globals.css';
const baloo = Baloo_2({
subsets: ['latin', 'devanagari'],
weight: ['400', '600', '700', '800'],
variable: '--font-baloo',
display: 'swap',
});
const vibes = Great_Vibes({
subsets: ['latin'],
weight: ['400'],
variable: '--font-vibes',
display: 'swap',
});
const caveat = Caveat({
subsets: ['latin'],
weight: ['400', '600', '700'],
variable: '--font-caveat',
display: 'swap',
});
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
export const metadata: Metadata = {
title: 'समोसाचाट — samosaChaat',
description: 'Crafted with care. For India, from India. A warm, desi-flavored chat experience powered by nanochat.',
icons: { icon: '/logo.svg' },
};
export const viewport: Viewport = {
themeColor: '#fff8e7',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${baloo.variable} ${vibes.variable} ${caveat.variable} ${inter.variable}`}>
<body className="min-h-dvh bg-white text-gray-900">
<SessionBoundary>{children}</SessionBoundary>
</body>
</html>
);
}

View File

@ -0,0 +1,78 @@
import Link from 'next/link';
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
import MandalaArt from '@/components/login/MandalaArt';
import OAuthButtons from '@/components/login/OAuthButtons';
import SamosaLogo from '@/components/svg/SamosaLogo';
export const metadata = { title: 'Sign in — samosaChaat' };
export default async function LoginPage() {
const session = await auth();
if (session?.user) redirect('/chat');
return (
<main className="min-h-dvh grid grid-cols-1 lg:grid-cols-2 bg-white">
<div className="hidden lg:block lg:min-h-dvh">
<MandalaArt />
</div>
<div className="flex items-center justify-center px-5 py-10 md:px-10">
<div className="w-full max-w-md">
<Link href="/" className="flex items-center gap-2 mb-8">
<SamosaLogo size={36} />
<span className="font-baloo font-bold text-xl text-gray-900">samosaChaat</span>
</Link>
<h1 className="font-baloo font-bold text-3xl md:text-4xl text-gray-900">Login to your account</h1>
<p className="mt-2 text-sm text-gray-500">
Welcome back. Pick up the conversation where you left it.
</p>
<div className="mt-8">
<Suspense fallback={<div className="h-24" aria-hidden="true" />}>
<OAuthButtons />
</Suspense>
</div>
<div className="flex items-center gap-3 my-6" aria-hidden="true">
<span className="flex-1 h-px bg-gray-200" />
<span className="text-xs uppercase tracking-wider text-gray-400">OR</span>
<span className="flex-1 h-px bg-gray-200" />
</div>
<div className="space-y-3">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email (coming soon)
</label>
<input
id="email"
type="email"
disabled
placeholder="you@example.com"
className="w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 text-gray-400 cursor-not-allowed"
/>
<button
disabled
type="button"
className="w-full py-3 rounded-lg bg-gold/40 text-white font-medium cursor-not-allowed"
>
Continue with email
</button>
</div>
<p className="mt-6 text-sm text-gray-500 text-center">
Don&apos;t have an account?{' '}
<Link href="/login" className="text-chutney-green font-medium hover:underline">
Create one
</Link>
</p>
<p className="mt-10 text-xs text-gray-400 text-center">
By continuing, you agree to our Terms and acknowledge our Privacy Policy.
</p>
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,19 @@
import LandingNav from '@/components/LandingNav';
import LandingFooter from '@/components/LandingFooter';
import Hero from '@/components/landing/Hero';
import { SamosaIllustration, KettleIllustration } from '@/components/landing/Illustrations';
import Doodles from '@/components/svg/Doodles';
export default function LandingPage() {
return (
<main className="relative flex min-h-dvh flex-col bg-white overflow-x-hidden">
<LandingNav />
<Doodles />
<Hero />
<SamosaIllustration />
<KettleIllustration />
<div className="flex-1" />
<LandingFooter />
</main>
);
}

36
services/frontend/auth.ts Normal file
View File

@ -0,0 +1,36 @@
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
secret: process.env.NEXTAUTH_SECRET,
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
pages: {
signIn: '/login',
},
session: { strategy: 'jwt' },
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
if (session.user && token.sub) {
(session.user as { id?: string }).id = token.sub;
}
return session;
},
},
});

View File

@ -0,0 +1,20 @@
export default function LandingFooter() {
return (
<footer className="flex flex-col sm:flex-row justify-between items-center gap-1 px-4 md:px-9 py-3 font-caveat text-sm text-gray-400 flex-shrink-0">
<span>&copy; 2026 samosachaat.art · Crafted with care. For India, from India.</span>
<span className="text-xs text-gray-400">
Built on{' '}
<a
href="https://github.com/karpathy/nanochat"
target="_blank"
rel="noopener noreferrer"
className="text-warm-grey hover:text-gray-600"
>
nanochat
</a>{' '}
by Andrej Karpathy
</span>
<a href="#" className="hover:text-gray-600">Terms and Policies</a>
</footer>
);
}

View File

@ -0,0 +1,47 @@
import Link from 'next/link';
import ToranSvg from './svg/ToranSvg';
export default function LandingNav() {
return (
<nav className="relative flex justify-between items-start px-4 md:px-9 pt-4 pb-2 z-10 flex-shrink-0">
<div className="flex items-center gap-2">
<Link href="/" aria-label="Home" className="transition-transform hover:scale-105">
<svg viewBox="0 0 30 30" width={30} height={30} fill="none" stroke="#444" strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round">
<path d="M4,16 L15.2,5 L26,16" />
<path d="M7,14.5 L7,26 L23,26 L23,14.5" />
<rect x="12" y="19" width="6" height="7" rx="0.5" />
<rect x="8.5" y="16" width="3" height="2.8" rx="0.3" />
<rect x="18.5" y="16" width="3" height="2.8" rx="0.3" />
</svg>
</Link>
<Link
href="/"
className="relative font-caveat text-[1.2rem] md:text-[1.35rem] font-semibold text-gray-800 after:content-[''] after:absolute after:-bottom-0.5 after:left-0 after:w-full after:h-[1.5px] after:bg-gray-500 after:rounded after:-rotate-[0.5deg]"
>
samosaChaat
</Link>
</div>
<div className="absolute left-1/2 top-0 origin-top transform -translate-x-1/2 animate-pendulum z-[5]">
<ToranSvg />
</div>
<div className="flex items-center gap-4 font-caveat text-[1.05rem] text-gray-600 pt-1">
<a
href="https://instagram.com/samosachaat.art"
target="_blank"
rel="noopener noreferrer"
className="hidden sm:inline hover:text-brown transition-colors"
>
@samosachaat
</a>
<Link
href="/login"
className="px-3 py-1 rounded-full border border-warm-grey text-brown bg-cream-light hover:bg-cream transition-colors"
>
Sign in
</Link>
</div>
</nav>
);
}

View File

@ -0,0 +1,8 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export default function SessionBoundary({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@ -0,0 +1,85 @@
'use client';
import { useEffect, useRef } from 'react';
import { Send, Square } from 'lucide-react';
import clsx from 'clsx';
interface Props {
value: string;
onChange: (v: string) => void;
onSubmit: () => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
}
export default function ChatInput({ value, onChange, onSubmit, onStop, isStreaming, disabled }: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}, [value]);
useEffect(() => {
ref.current?.focus();
}, []);
const canSend = value.trim().length > 0 && !disabled && !isStreaming;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (canSend) onSubmit();
}
};
return (
<div className="sticky bottom-0 bg-white pt-3 pb-[calc(1rem+env(safe-area-inset-bottom))] px-4">
<div className="max-w-3xl mx-auto flex items-end gap-3">
<div className="flex-1 relative">
<textarea
ref={ref}
rows={1}
placeholder="What's on your mind?"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
className="w-full resize-none px-4 py-3 pr-12 rounded-2xl border border-warm-grey bg-white text-gray-900 placeholder-[#b8a88a] focus:outline-none focus:border-gold focus:ring-2 focus:ring-gold/20 min-h-[54px] max-h-[200px] leading-relaxed text-[0.95rem]"
/>
</div>
{isStreaming && onStop ? (
<button
type="button"
onClick={onStop}
className="w-12 h-12 flex-shrink-0 rounded-full bg-chutney-red text-white flex items-center justify-center hover:brightness-110 transition"
aria-label="Stop generating"
>
<Square size={18} fill="currentColor" />
</button>
) : (
<button
type="button"
onClick={onSubmit}
disabled={!canSend}
className={clsx(
'w-12 h-12 flex-shrink-0 rounded-full flex items-center justify-center transition',
canSend
? 'bg-gold hover:bg-gold-dark text-white'
: 'bg-gold/30 text-white cursor-not-allowed',
)}
aria-label="Send message"
>
<Send size={18} />
</button>
)}
</div>
<p className="max-w-3xl mx-auto mt-2 text-[11px] text-gray-400 text-center">
Tip: try <code className="text-brown">/temperature 0.7</code>, <code className="text-brown">/topk 40</code>, or <code className="text-brown">/clear</code>.
</p>
</div>
);
}

View File

@ -0,0 +1,187 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
import { PanelLeftOpen } from 'lucide-react';
import MessageBubble from './MessageBubble';
import EmptyState from './EmptyState';
import ChatInput from './ChatInput';
import { useChatStore } from '@/store/chatStore';
import { useSSE } from '@/hooks/useSSE';
import { parseSlashCommand } from '@/lib/slashCommands';
import type { Message } from '@/types/chat';
export default function ChatWindow() {
const { data: session } = useSession();
const {
conversations,
currentConversationId,
model,
temperature,
topK,
sidebarOpen,
toggleSidebar,
newConversation,
appendMessage,
updateMessage,
setTemperature,
setTopK,
} = useChatStore();
const [draft, setDraft] = useState('');
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
const streamingBufferRef = useRef('');
const scrollRef = useRef<HTMLDivElement>(null);
const active = useMemo(
() => conversations.find((c) => c.id === currentConversationId) ?? null,
[conversations, currentConversationId],
);
const messages: Message[] = active?.messages ?? [];
const isEmpty = messages.length === 0;
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, []);
useEffect(() => {
scrollToBottom();
}, [messages.length, streamingMsgId, scrollToBottom]);
const { start, stop, isStreaming } = useSSE('/api/chat/stream', {
onToken: (token) => {
streamingBufferRef.current += token;
if (streamingMsgId && currentConversationId) {
updateMessage(currentConversationId, streamingMsgId, streamingBufferRef.current);
}
},
onDone: () => {
setStreamingMsgId(null);
streamingBufferRef.current = '';
},
onError: (err) => {
console.error('[chat] stream error:', err);
if (streamingMsgId && currentConversationId) {
updateMessage(
currentConversationId,
streamingMsgId,
`⚠️ Error: ${err.message}. Using mock responses requires only the frontend; cloud streaming requires CHAT_API_URL.`,
);
}
setStreamingMsgId(null);
streamingBufferRef.current = '';
},
});
const ensureConversation = useCallback(() => {
if (currentConversationId) return currentConversationId;
return newConversation();
}, [currentConversationId, newConversation]);
const handleSend = useCallback(
async (rawInput?: string) => {
const text = (rawInput ?? draft).trim();
if (!text || isStreaming) return;
const convId = ensureConversation();
const slash = parseSlashCommand(text, { temperature, topK });
if (slash.handled) {
setDraft('');
if (slash.setTemperature !== undefined) setTemperature(slash.setTemperature);
if (slash.setTopK !== undefined) setTopK(slash.setTopK);
if (slash.clear) {
newConversation();
return;
}
if (slash.consoleMessage) {
appendMessage(convId, { role: 'console', content: slash.consoleMessage });
}
return;
}
setDraft('');
appendMessage(convId, { role: 'user', content: text });
const assistantId = appendMessage(convId, { role: 'assistant', content: '' });
setStreamingMsgId(assistantId);
streamingBufferRef.current = '';
const history = [
...(useChatStore.getState().conversations.find((c) => c.id === convId)?.messages ?? []),
]
.filter((m) => m.role === 'user' || m.role === 'assistant')
.slice(0, -1)
.map((m) => ({ role: m.role, content: m.content }));
await start({ messages: history, model, temperature, topK });
},
[
draft,
isStreaming,
ensureConversation,
temperature,
topK,
appendMessage,
model,
setTemperature,
setTopK,
newConversation,
start,
],
);
return (
<section className="flex-1 flex flex-col min-w-0 bg-white">
<header className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-cream-border">
<div className="flex items-center gap-3">
{!sidebarOpen && (
<button
type="button"
onClick={toggleSidebar}
aria-label="Open sidebar"
className="p-1.5 rounded hover:bg-cream text-brown-light"
>
<PanelLeftOpen size={18} />
</button>
)}
<h1 className="font-baloo font-semibold text-lg text-gray-900">Chat Completions</h1>
<span className="hidden sm:inline text-xs px-2 py-0.5 rounded-full border border-warm-grey bg-cream-light text-brown">
{model}
</span>
</div>
<div className="text-xs text-gray-500">
{session?.user?.name ? `Hi, ${session.user.name.split(' ')[0]}` : ''}
</div>
</header>
<div ref={scrollRef} className="flex-1 overflow-y-auto nice-scrollbar">
<div className="max-w-3xl mx-auto px-4 md:px-6 py-6 flex flex-col min-h-full">
{isEmpty ? (
<EmptyState onPick={(p) => handleSend(p)} />
) : (
<div className="flex flex-col">
{messages.map((m) => (
<MessageBubble
key={m.id}
message={m}
isStreaming={streamingMsgId === m.id && isStreaming}
/>
))}
</div>
)}
</div>
</div>
<ChatInput
value={draft}
onChange={setDraft}
onSubmit={() => handleSend()}
onStop={stop}
isStreaming={isStreaming}
/>
</section>
);
}

View File

@ -0,0 +1,39 @@
'use client';
import { Sparkles, BookOpen, Code2, Smile } from 'lucide-react';
const CHIPS = [
{ icon: BookOpen, label: 'Summarize a topic', prompt: 'Summarize the history of samosas in 3 paragraphs.' },
{ icon: Sparkles, label: 'Explain a concept', prompt: 'Explain transformers to a curious beginner.' },
{ icon: Code2, label: 'Write some code', prompt: 'Write a Python function that reverses a linked list.' },
{ icon: Smile, label: 'Tell me a joke', prompt: 'Tell me a joke about chai.' },
];
export default function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-4 text-center">
<h2 className="font-baloo font-bold text-3xl md:text-4xl text-gray-900 mb-2">
How can I help you today?
</h2>
<p className="font-caveat text-lg text-brown-light mb-8">
Ask anything a doubt, a recipe, a code snippet, or a fresh idea.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-xl">
{CHIPS.map(({ icon: Icon, label, prompt }) => (
<button
key={label}
type="button"
onClick={() => onPick(prompt)}
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-cream-border bg-cream-light hover:bg-cream text-left transition-colors"
>
<Icon size={18} className="text-gold flex-shrink-0" />
<div>
<div className="text-sm font-medium text-gray-800">{label}</div>
<div className="text-xs text-gray-500 truncate">{prompt}</div>
</div>
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
import { Check, Copy } from 'lucide-react';
import clsx from 'clsx';
import type { Message } from '@/types/chat';
import SteamTyping from '@/components/svg/SteamTyping';
interface Props {
message: Message;
isStreaming?: boolean;
}
function CodeBlock({ inline, className, children, ...props }: {
inline?: boolean;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLElement>) {
const [copied, setCopied] = useState(false);
const content = String(children ?? '').replace(/\n$/, '');
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
const copy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
return (
<div className="relative group">
<button
type="button"
onClick={copy}
aria-label="Copy code"
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700/70 text-slate-100 opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-opacity"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
<pre>
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}
export default function MessageBubble({ message, isStreaming }: Props) {
const isUser = message.role === 'user';
const isConsole = message.role === 'console';
if (isConsole) {
return (
<div className="flex justify-start mb-2 animate-fade-in">
<div className="font-mono text-sm bg-cream-light border border-cream-border text-brown-light px-4 py-3 rounded-xl max-w-[80%]">
{message.content}
</div>
</div>
);
}
return (
<div className={clsx('flex mb-3 animate-fade-in', isUser ? 'justify-end' : 'justify-start')}>
<div
className={clsx(
'max-w-[85%] md:max-w-[75%]',
isUser
? 'bg-cream border border-cream-border rounded-[1.25rem] px-4 py-3'
: 'bg-white px-2 py-1',
)}
>
{!isUser && isStreaming && message.content.length === 0 ? (
<SteamTyping />
) : isUser ? (
<div className="whitespace-pre-wrap leading-relaxed text-[0.95rem] text-gray-900">
{message.content}
</div>
) : (
<div className="markdown-body text-[0.95rem] text-gray-900 leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{ code: CodeBlock as never }}
>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,148 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { useSession, signOut } from 'next-auth/react';
import { Plus, PanelLeftClose, PanelLeftOpen, LogOut, ChevronDown } from 'lucide-react';
import SamosaLogo from '@/components/svg/SamosaLogo';
import { useChatStore, groupConversations, MODEL_OPTIONS } from '@/store/chatStore';
import clsx from 'clsx';
export default function Sidebar() {
const { data: session } = useSession();
const {
conversations,
currentConversationId,
sidebarOpen,
model,
setModel,
toggleSidebar,
newConversation,
selectConversation,
hydrateMockConversations,
} = useChatStore();
useEffect(() => {
hydrateMockConversations();
}, [hydrateMockConversations]);
const grouped = groupConversations(conversations);
return (
<aside
className={clsx(
'flex flex-col bg-cream-light border-r border-cream-border transition-all duration-300 ease-in-out overflow-hidden',
sidebarOpen ? 'w-[260px]' : 'w-0 md:w-[56px]',
)}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-cream-border">
<Link href="/" className={clsx('flex items-center gap-2 overflow-hidden', !sidebarOpen && 'md:hidden')}>
<SamosaLogo size={28} />
<span className="font-baloo font-bold text-base text-gray-900 whitespace-nowrap">samosaChaat</span>
</Link>
<button
aria-label="Toggle sidebar"
onClick={toggleSidebar}
className="p-1.5 rounded hover:bg-cream text-brown-light"
>
{sidebarOpen ? <PanelLeftClose size={18} /> : <PanelLeftOpen size={18} />}
</button>
</div>
{sidebarOpen && (
<>
<div className="px-3 py-3">
<button
type="button"
onClick={() => newConversation()}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-gold/60 bg-white hover:bg-cream text-brown font-medium text-sm transition-colors"
>
<Plus size={16} className="text-gold" />
New chat
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 nice-scrollbar">
{Object.entries(grouped).map(([group, items]) => {
if (items.length === 0) return null;
return (
<div key={group} className="mb-4">
<div className="px-2 mb-1 text-[11px] uppercase tracking-wider text-gray-400 font-medium">
{group}
</div>
<ul className="space-y-0.5">
{items.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => selectConversation(c.id)}
className={clsx(
'w-full text-left px-2.5 py-1.5 rounded text-sm truncate transition-colors',
c.id === currentConversationId
? 'bg-cream text-brown font-medium'
: 'text-gray-700 hover:bg-cream/70',
)}
title={c.title}
>
{c.title}
</button>
</li>
))}
</ul>
</div>
);
})}
</div>
<div className="px-3 py-3 border-t border-cream-border space-y-3">
<div>
<label htmlFor="model-select" className="block text-[11px] uppercase tracking-wider text-gray-400 mb-1">
Model
</label>
<div className="relative">
<select
id="model-select"
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full appearance-none px-3 py-2 pr-8 rounded-lg border border-cream-border bg-white text-sm text-gray-800 focus:outline-none focus:border-gold"
>
{MODEL_OPTIONS.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
<ChevronDown
size={14}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-1">
<div className="h-8 w-8 rounded-full bg-gold/20 text-brown flex items-center justify-center text-sm font-semibold">
{(session?.user?.name ?? 'G')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">
{session?.user?.name ?? 'Guest'}
</div>
<div className="text-xs text-gray-500 truncate">
{session?.user?.email ?? 'Not signed in'}
</div>
</div>
<button
type="button"
aria-label="Sign out"
onClick={() => signOut({ callbackUrl: '/' })}
className="p-1.5 rounded hover:bg-cream text-gray-500 hover:text-brown"
>
<LogOut size={16} />
</button>
</div>
</div>
</>
)}
</aside>
);
}

View File

@ -0,0 +1,58 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useSession } from 'next-auth/react';
export default function Hero() {
const { status } = useSession();
const ctaHref = status === 'authenticated' ? '/chat' : '/login';
return (
<section className="relative z-[2] text-center px-4 pt-6">
<motion.h1
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="font-baloo font-extrabold text-[clamp(3rem,7vw,5.5rem)] text-[#1a1a1a] leading-[1.1] -rotate-1 -mb-[0.35em] relative z-[2]"
>
</motion.h1>
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.15 }}
className="font-vibes text-[clamp(2rem,5vw,3.8rem)] text-[rgba(30,30,30,0.55)] leading-none rotate-[0.5deg] relative z-[1] -mt-[0.1em]"
>
samosaChaat
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mt-6 font-caveat text-lg md:text-xl text-brown-light max-w-xl mx-auto"
>
A warm, desi-flavored chat brewed from the nanochat research model, served with a side of chai.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.45 }}
className="mt-6"
>
<Link
href={ctaHref}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-gold hover:bg-gold-dark text-white font-baloo font-semibold text-base shadow-md transition-colors"
>
Start Chatting
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" />
<path d="M13 5l7 7-7 7" />
</svg>
</Link>
</motion.div>
</section>
);
}

View File

@ -0,0 +1,30 @@
import SamosaSvg from '@/components/svg/SamosaSvg';
import KettleSvg from '@/components/svg/KettleSvg';
import KettleSteam from '@/components/svg/KettleSteam';
export function SamosaIllustration() {
return (
<div className="fixed bottom-[5%] left-[5%] flex flex-col items-center z-[5] pointer-events-none hidden md:flex">
<div className="animate-float">
<SamosaSvg />
</div>
<span className="mt-1.5 inline-block font-caveat text-[1.1rem] text-brown-light bg-[#f5edd6] px-4 py-0.5 border border-[#d4c4a0] rounded-sm -rotate-3 shadow-sm">
Samosa
</span>
</div>
);
}
export function KettleIllustration() {
return (
<div className="fixed bottom-[5%] right-[5%] flex flex-col items-center z-[5] pointer-events-none hidden md:flex">
<div className="relative animate-wobble">
<KettleSteam />
<KettleSvg />
</div>
<span className="mt-1.5 inline-block font-caveat text-[1.1rem] text-brown-light bg-[#f5edd6] px-4 py-0.5 border border-[#d4c4a0] rounded-sm rotate-2 shadow-sm">
Chai
</span>
</div>
);
}

View File

@ -0,0 +1,70 @@
export default function MandalaArt() {
return (
<div className="relative w-full h-full overflow-hidden">
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(135deg, #ffb347 0%, #e8a838 40%, #fff8e7 100%)',
}}
/>
{/* Mandala overlay */}
<svg
className="absolute inset-0 w-full h-full opacity-80"
viewBox="0 0 600 600"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<defs>
<radialGradient id="mandala-glow" cx="50%" cy="50%">
<stop offset="0%" stopColor="#fff8e7" stopOpacity="0.55" />
<stop offset="60%" stopColor="#fff8e7" stopOpacity="0.05" />
<stop offset="100%" stopColor="#fff8e7" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="300" cy="300" r="280" fill="url(#mandala-glow)" />
{Array.from({ length: 24 }).map((_, i) => (
<g key={i} transform={`rotate(${(360 / 24) * i} 300 300)`}>
<path d="M300,60 Q312,160 300,260 Q288,160 300,60 Z" fill="#fff8e7" opacity="0.18" />
<circle cx="300" cy="90" r="6" fill="#fff" opacity="0.45" />
<circle cx="300" cy="130" r="3" fill="#c47f17" opacity="0.55" />
</g>
))}
{[260, 210, 160, 110, 60].map((r, i) => (
<circle
key={r}
cx="300"
cy="300"
r={r}
stroke="#fff8e7"
strokeWidth={i % 2 === 0 ? 1.5 : 0.8}
strokeDasharray={i % 2 === 0 ? '4 6' : undefined}
fill="none"
opacity="0.55"
/>
))}
<g transform="translate(300 300)">
<path
d="M0,-80 L32,-32 L80,0 L32,32 L0,80 L-32,32 L-80,0 L-32,-32 Z"
fill="#8b4d0a"
opacity="0.25"
/>
<circle r="28" fill="#fff8e7" opacity="0.9" />
<circle r="14" fill="#e8a838" />
</g>
</svg>
{/* Large brand type overlay */}
<div className="absolute inset-0 flex flex-col justify-end p-10 lg:p-14 text-white">
<h2 className="font-baloo font-extrabold text-5xl lg:text-6xl leading-tight drop-shadow-sm">
</h2>
<p className="font-vibes text-3xl lg:text-4xl opacity-90">samosaChaat</p>
<p className="mt-3 font-caveat text-xl opacity-90 max-w-md">
Chat in the language of your heart.<br />
A little desi, a lot thoughtful.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
'use client';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
function GoogleIcon() {
return (
<svg width={20} height={20} viewBox="0 0 48 48" aria-hidden="true">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
);
}
function GitHubIcon() {
return (
<svg width={20} height={20} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#111"
d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.38 7.86 10.9.58.11.79-.25.79-.56v-2c-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.04-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.76 2.69 1.25 3.35.95.1-.74.4-1.25.73-1.54-2.56-.29-5.25-1.28-5.25-5.69 0-1.26.45-2.29 1.19-3.09-.12-.29-.52-1.47.11-3.07 0 0 .97-.31 3.19 1.18a11.1 11.1 0 0 1 5.8 0c2.21-1.49 3.18-1.18 3.18-1.18.63 1.6.23 2.78.11 3.07.74.8 1.18 1.83 1.18 3.09 0 4.42-2.69 5.4-5.25 5.68.41.35.78 1.05.78 2.12v3.14c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5z"
/>
</svg>
);
}
export default function OAuthButtons() {
const params = useSearchParams();
const callbackUrl = params.get('callbackUrl') || '/chat';
const [busy, setBusy] = useState<string | null>(null);
const handleSignIn = async (provider: 'google' | 'github') => {
setBusy(provider);
try {
await signIn(provider, { callbackUrl });
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<button
type="button"
onClick={() => handleSignIn('google')}
disabled={busy !== null}
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-60 transition-colors font-sans text-sm font-medium text-gray-700"
>
<GoogleIcon />
<span>{busy === 'google' ? 'Redirecting…' : 'Continue with Google'}</span>
</button>
<button
type="button"
onClick={() => handleSignIn('github')}
disabled={busy !== null}
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-60 transition-colors font-sans text-sm font-medium text-gray-700"
>
<GitHubIcon />
<span>{busy === 'github' ? 'Redirecting…' : 'Continue with GitHub'}</span>
</button>
</div>
);
}

View File

@ -0,0 +1,33 @@
const DOODLES = [
{ className: 'top-[18%] right-[6%] rotate-[25deg]', svg: (
<svg viewBox="0 0 28 14" width={32} height={16}><path d="M2,7 C2,3 8,1 14,2 C20,3 26,7 26,10 C26,12 24,13 22,12 C18,10 10,5 6,6 C4,6.5 2,9 2,7Z" fill="#34A85A" opacity="0.7" /></svg>
) },
{ className: 'bottom-[28%] left-[3%] -rotate-[15deg]', svg: (
<svg viewBox="0 0 18 18" width={20} height={20}><circle cx="9" cy="9" r="7" fill="#F4D03F" opacity="0.6" /><circle cx="9" cy="9" r="5" fill="#F7DC6F" opacity="0.35" /></svg>
) },
{ className: 'top-[45%] right-[3%] rotate-[140deg]', svg: (
<svg viewBox="0 0 24 12" width={28} height={14}><path d="M2,6 C2,3 6,1 12,2 C18,3 22,6 22,9 C22,11 20,11 18,10 C14,8 8,4 4,5 C3,5.5 2,7.5 2,6Z" fill="#2E8B57" opacity="0.6" /></svg>
) },
{ className: 'bottom-[18%] left-[42%] rotate-[10deg]', svg: (
<svg viewBox="0 0 16 16" width={16} height={16}><circle cx="8" cy="8" r="6" fill="#F4D03F" opacity="0.45" /></svg>
) },
{ className: 'top-[32%] left-[7%] -rotate-[40deg]', svg: (
<svg viewBox="0 0 22 11" width={24} height={12}><path d="M2,5 C2,2 6,1 11,2 C16,3 20,5 20,8 C20,10 18,10 16,9 C12,7 8,3 4,4.5 C3,5 2,7 2,5Z" fill="#34A85A" opacity="0.55" /></svg>
) },
];
export default function Doodles() {
return (
<>
{DOODLES.map((d, i) => (
<div
key={i}
className={`fixed pointer-events-none opacity-35 z-0 transition-opacity duration-500 hidden md:block ${d.className}`}
aria-hidden="true"
>
{d.svg}
</div>
))}
</>
);
}

View File

@ -0,0 +1,15 @@
export default function KettleSteam({ className = '' }: { className?: string }) {
return (
<svg className={`absolute -top-10 -right-2 pointer-events-none ${className}`} viewBox="0 0 60 55" width={60} height={55} aria-hidden="true">
<g style={{ transformBox: 'fill-box', transformOrigin: 'bottom center' }} className="animate-steam-1">
<path d="M15,48 Q10,36 18,26 Q26,16 18,4" stroke="#aaa" strokeWidth="2" fill="none" strokeLinecap="round" opacity="0.4" />
</g>
<g style={{ transformBox: 'fill-box', transformOrigin: 'bottom center' }} className="animate-steam-2">
<path d="M30,48 Q36,36 28,26 Q20,16 28,4" stroke="#bbb" strokeWidth="1.8" fill="none" strokeLinecap="round" opacity="0.3" />
</g>
<g style={{ transformBox: 'fill-box', transformOrigin: 'bottom center' }} className="animate-steam-3">
<path d="M45,48 Q40,36 46,28 Q52,18 44,6" stroke="#aaa" strokeWidth="1.5" fill="none" strokeLinecap="round" opacity="0.3" />
</g>
</svg>
);
}

View File

@ -0,0 +1,35 @@
export default function KettleSvg({ className = '', width = 180, height = 165 }: { className?: string; width?: number; height?: number }) {
return (
<svg className={`kettle-svg ${className}`} viewBox="0 0 200 185" width={width} height={height} aria-hidden="true">
<defs>
<linearGradient id="kettleG" x1="20%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stopColor="#d4a543" />
<stop offset="45%" stopColor="#b8862a" />
<stop offset="100%" stopColor="#8b6914" />
</linearGradient>
<radialGradient id="kettleHL" cx="35%" cy="38%">
<stop offset="0%" stopColor="#e8c860" stopOpacity="0.4" />
<stop offset="100%" stopColor="#d4a543" stopOpacity="0" />
</radialGradient>
<filter id="kettleSh">
<feDropShadow dx="1" dy="3" stdDeviation="3" floodColor="#00000012" />
</filter>
</defs>
<ellipse cx="95" cy="178" rx="55" ry="6" fill="#00000008" />
<path d="M28,118 C28,155 55,175 98,175 C141,175 168,155 168,118 C168,92 152,78 142,73 L54,73 C44,78 28,92 28,118Z" fill="url(#kettleG)" filter="url(#kettleSh)" />
<path d="M28,118 C28,155 55,175 98,175 C141,175 168,155 168,118 C168,92 152,78 142,73 L54,73 C44,78 28,92 28,118Z" fill="url(#kettleHL)" />
<rect x="58" y="54" width="80" height="19" rx="3" fill="#a07828" />
<path d="M54,57 Q98,42 142,57" fill="#a07828" stroke="#8b6914" strokeWidth="0.8" />
<circle cx="98" cy="44" r="5" fill="#8b6914" stroke="#705510" strokeWidth="0.8" />
<path d="M56,52 Q38,18 98,10 Q158,18 140,52" stroke="#8b6914" strokeWidth="4.5" fill="none" strokeLinecap="round" />
<path d="M56,52 Q38,18 98,10 Q158,18 140,52" stroke="#c4a040" strokeWidth="1.5" fill="none" strokeLinecap="round" opacity="0.3" />
<path d="M163,98 Q178,90 182,76 Q184,70 180,66" stroke="#a07828" strokeWidth="6" fill="none" strokeLinecap="round" />
<path d="M163,98 Q178,90 182,76 Q184,70 180,66" stroke="#c4a040" strokeWidth="2" fill="none" strokeLinecap="round" opacity="0.25" />
<ellipse cx="98" cy="112" rx="66" ry="2.5" fill="#c4a040" opacity="0.25" />
<ellipse cx="98" cy="128" rx="64" ry="2" fill="#c4a040" opacity="0.18" />
<path d="M58,103 Q63,98 68,103 Q73,108 78,103" stroke="#705510" strokeWidth="0.7" fill="none" opacity="0.25" />
<path d="M118,103 Q123,98 128,103 Q133,108 138,103" stroke="#705510" strokeWidth="0.7" fill="none" opacity="0.25" />
<path d="M55,95 Q60,110 62,130" stroke="#e8c860" strokeWidth="3" fill="none" opacity="0.2" strokeLinecap="round" />
</svg>
);
}

View File

@ -0,0 +1,18 @@
export default function SamosaLogo({ size = 32 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 400 400" aria-hidden="true">
<defs>
<linearGradient id="logo-fill" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#e8a838" />
<stop offset="100%" stopColor="#c47f17" />
</linearGradient>
</defs>
<path d="M200 60 L340 320 L60 320 Z" fill="url(#logo-fill)" stroke="#a0620f" strokeWidth={6} strokeLinejoin="round" />
<path d="M200 100 L160 220" stroke="#c47f17" strokeWidth={3} fill="none" opacity="0.5" />
<path d="M200 100 L240 220" stroke="#c47f17" strokeWidth={3} fill="none" opacity="0.5" />
<circle cx="170" cy="230" r="10" fill="#fff" opacity="0.85" />
<circle cx="200" cy="230" r="10" fill="#fff" opacity="0.85" />
<circle cx="230" cy="230" r="10" fill="#fff" opacity="0.85" />
</svg>
);
}

View File

@ -0,0 +1,34 @@
export default function SamosaSvg({ className = '', width = 200, height = 175 }: { className?: string; width?: number; height?: number }) {
return (
<svg className={className} viewBox="0 0 220 195" width={width} height={height} aria-hidden="true">
<defs>
<linearGradient id="samosaG" x1="30%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stopColor="#edb44c" />
<stop offset="35%" stopColor="#d4940a" />
<stop offset="75%" stopColor="#b87a08" />
<stop offset="100%" stopColor="#8b5e0a" />
</linearGradient>
<linearGradient id="samosaHighlight" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#f5d080" stopOpacity="0.5" />
<stop offset="100%" stopColor="#d4940a" stopOpacity="0" />
</linearGradient>
<filter id="samosaSh">
<feDropShadow dx="1" dy="3" stdDeviation="3" floodColor="#00000015" />
</filter>
</defs>
<ellipse cx="108" cy="178" rx="58" ry="7" fill="#00000008" />
<path d="M108,18 C88,18 38,72 28,128 C24,150 35,168 55,170 L162,170 C182,168 192,150 188,128 C178,72 128,18 108,18Z" fill="url(#samosaG)" filter="url(#samosaSh)" />
<path d="M108,18 C88,18 38,72 28,128 C24,150 35,168 55,170 L162,170 C182,168 192,150 188,128 C178,72 128,18 108,18Z" fill="url(#samosaHighlight)" />
<path d="M108,28 Q102,65 92,105 Q87,128 82,148" stroke="#a06808" strokeWidth="1.2" fill="none" opacity="0.35" />
<path d="M108,28 Q114,65 124,105 Q129,128 134,148" stroke="#a06808" strokeWidth="1.2" fill="none" opacity="0.35" />
<path d="M90,50 Q95,47 100,50 Q105,53 110,50 Q115,47 120,50 Q125,53 128,50" stroke="#8b5e0a" strokeWidth="1" fill="none" opacity="0.3" />
<path d="M55,170 Q80,164 108,166 Q136,168 162,170" stroke="#8b5e0a" strokeWidth="1.5" fill="none" opacity="0.4" />
<path d="M88,50 Q94,68 96,90" stroke="#f5d080" strokeWidth="2.5" fill="none" opacity="0.35" strokeLinecap="round" />
<ellipse cx="185" cy="158" rx="14" ry="9" fill="#2d8a4e" opacity="0.65" />
<ellipse cx="183" cy="156" rx="9" ry="5.5" fill="#3aa85e" opacity="0.45" />
<circle cx="60" cy="175" r="2" fill="#c4940a" opacity="0.3" />
<circle cx="165" cy="176" r="1.5" fill="#c4940a" opacity="0.25" />
<circle cx="145" cy="180" r="1.8" fill="#b87a08" opacity="0.2" />
</svg>
);
}

View File

@ -0,0 +1,10 @@
export default function SteamTyping() {
return (
<div className="inline-flex items-end h-7 px-1" aria-label="assistant thinking">
<span className="inline-block w-[3px] h-[14px] mx-[3px] rounded-sm bg-warm-grey origin-bottom animate-steam-type" />
<span className="inline-block w-[3px] h-[18px] mx-[3px] rounded-sm bg-warm-grey origin-bottom animate-steam-type [animation-delay:0.25s]" />
<span className="inline-block w-[3px] h-[12px] mx-[3px] rounded-sm bg-warm-grey origin-bottom animate-steam-type [animation-delay:0.5s]" />
<span className="inline-block w-[3px] h-[16px] mx-[3px] rounded-sm bg-warm-grey origin-bottom animate-steam-type [animation-delay:0.75s]" />
</div>
);
}

View File

@ -0,0 +1,20 @@
export default function ToranSvg({ width = 48, height = 100 }: { width?: number; height?: number }) {
return (
<svg viewBox="0 0 50 105" width={width} height={height} fill="none" aria-hidden="true">
<line x1="25" y1="0" x2="25" y2="88" stroke="#555" strokeWidth="1" />
<path d="M23,16 C18,22 13,34 14,46 C14,49 16,50 18,47 C20,43 22,28 23,20Z" fill="#2E8B57" stroke="#1a6b3a" strokeWidth="0.4" />
<path d="M23,16 L22,11" stroke="#5a7c4f" strokeWidth="1.4" strokeLinecap="round" />
<path d="M27,20 C32,26 37,38 36,50 C36,53 34,54 32,51 C30,47 28,32 27,24Z" fill="#34A85A" stroke="#228B44" strokeWidth="0.4" />
<path d="M27,20 L28,15" stroke="#5a7c4f" strokeWidth="1.4" strokeLinecap="round" />
<path d="M23,35 C17,42 13,52 15,61 C15,64 17,64 19,62 C21,58 22,45 23,39Z" fill="#2E8B57" stroke="#1a6b3a" strokeWidth="0.4" />
<path d="M23,35 L22,31" stroke="#5a7c4f" strokeWidth="1.3" strokeLinecap="round" />
<path d="M27,42 C31,47 34,54 33,61 C33,63 31,63 30,61 C29,58 28,49 27,45Z" fill="#34A85A" stroke="#228B44" strokeWidth="0.4" />
<path d="M27,42 L28,39" stroke="#5a7c4f" strokeWidth="1.2" strokeLinecap="round" />
<ellipse cx="25" cy="82" rx="12" ry="10.5" fill="#F4D03F" />
<ellipse cx="25" cy="82" rx="9" ry="8" fill="#F7DC6F" opacity="0.5" />
<ellipse cx="23" cy="80" rx="5" ry="4" fill="#FAEA7A" opacity="0.35" />
<path d="M25,71 Q26,68 25,66" stroke="#C5A828" strokeWidth="1.5" strokeLinecap="round" fill="none" />
<circle cx="25" cy="65.5" r="1.5" fill="#8B9A46" />
</svg>
);
}

View File

@ -0,0 +1,93 @@
'use client';
import { useCallback, useRef, useState } from 'react';
export interface SSEOptions {
onToken?: (token: string, gpu?: number) => void;
onDone?: () => void;
onError?: (err: Error) => void;
}
export interface StreamRequest {
messages: Array<{ role: string; content: string }>;
model?: string;
temperature?: number;
topK?: number;
}
export function useSSE(endpoint: string, options: SSEOptions = {}) {
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = useCallback(
async (body: StreamRequest) => {
stop();
const ac = new AbortController();
abortRef.current = ac;
setIsStreaming(true);
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: ac.signal,
});
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
const data = JSON.parse(payload);
if (data.done) {
options.onDone?.();
setIsStreaming(false);
return;
}
if (typeof data.token === 'string') {
options.onToken?.(data.token, data.gpu);
}
} catch {
/* swallow malformed chunks */
}
}
}
options.onDone?.();
} catch (err) {
if ((err as Error).name !== 'AbortError') {
options.onError?.(err as Error);
}
} finally {
setIsStreaming(false);
abortRef.current = null;
}
},
[endpoint, options, stop],
);
return { start, stop, isStreaming };
}

View File

@ -0,0 +1,49 @@
export interface SlashResult {
handled: boolean;
consoleMessage?: string;
clear?: boolean;
setTemperature?: number;
setTopK?: number;
}
export function parseSlashCommand(
raw: string,
state: { temperature: number; topK: number },
): SlashResult {
const line = raw.trim();
if (!line.startsWith('/')) return { handled: false };
const [cmd, arg] = line.split(/\s+/);
switch (cmd.toLowerCase()) {
case '/temperature': {
if (arg === undefined) {
return { handled: true, consoleMessage: `Current temperature: ${state.temperature}` };
}
const t = parseFloat(arg);
if (isNaN(t) || t < 0 || t > 2) {
return { handled: true, consoleMessage: 'Invalid temperature. Must be between 0.0 and 2.0' };
}
return { handled: true, setTemperature: t, consoleMessage: `Temperature set to ${t}` };
}
case '/topk': {
if (arg === undefined) {
return { handled: true, consoleMessage: `Current top-k: ${state.topK}` };
}
const k = parseInt(arg, 10);
if (isNaN(k) || k < 1 || k > 200) {
return { handled: true, consoleMessage: 'Invalid top-k. Must be between 1 and 200' };
}
return { handled: true, setTopK: k, consoleMessage: `Top-k set to ${k}` };
}
case '/clear':
return { handled: true, clear: true };
case '/help':
return {
handled: true,
consoleMessage:
'Commands:\n/temperature [0-2] — sampling temperature\n/topk [1-200] — top-k sampling\n/clear — clear conversation\n/help — show this help',
};
default:
return { handled: true, consoleMessage: `Unknown command: ${cmd}` };
}
}

View File

@ -0,0 +1,19 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const { nextUrl } = req;
const isAuthed = !!req.auth;
const isChatRoute = nextUrl.pathname.startsWith('/chat');
if (isChatRoute && !isAuthed) {
const loginUrl = new URL('/login', nextUrl);
loginUrl.searchParams.set('callbackUrl', nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
});
export const config = {
matcher: ['/chat/:path*'],
};

5
services/frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
experimental: {
typedRoutes: false,
},
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'lh3.googleusercontent.com' },
{ protocol: 'https', hostname: 'avatars.githubusercontent.com' },
],
},
};
export default nextConfig;

7960
services/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
{
"name": "samosachaat-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "14.2.15",
"next-auth": "5.0.0-beta.22",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.5",
"framer-motion": "^11.11.8",
"lucide-react": "^0.451.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"rehype-highlight": "^7.0.0",
"highlight.js": "^11.10.0",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/node": "^20.16.10",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.15"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,20 @@
<svg width="400" height="400" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="samosa-fill" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#e8a838;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#c47f17;stop-opacity:1"/>
</linearGradient>
<linearGradient id="samosa-shadow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#b5690e;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#8b4d0a;stop-opacity:1"/>
</linearGradient>
</defs>
<path d="M200 60 L340 320 L60 320 Z" fill="url(#samosa-fill)" stroke="#a0620f" stroke-width="6" stroke-linejoin="round"/>
<path d="M200 100 L160 220" stroke="#c47f17" stroke-width="3" fill="none" opacity="0.5"/>
<path d="M200 100 L240 220" stroke="#c47f17" stroke-width="3" fill="none" opacity="0.5"/>
<path d="M140 260 L260 260" stroke="#c47f17" stroke-width="3" fill="none" opacity="0.4"/>
<path d="M120 290 L280 290" stroke="#c47f17" stroke-width="2" fill="none" opacity="0.3"/>
<circle cx="170" cy="230" r="10" fill="#fff" opacity="0.85"/>
<circle cx="200" cy="230" r="10" fill="#fff" opacity="0.85"/>
<circle cx="230" cy="230" r="10" fill="#fff" opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,191 @@
'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;
}

View File

@ -0,0 +1,74 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
gold: '#e8a838',
'gold-dark': '#d4940a',
brown: '#8b4d0a',
'brown-light': '#5a3e1b',
cream: '#fff8e7',
'cream-light': '#fffdf5',
'cream-border': '#f0e0b8',
'chutney-green': '#2d8a4e',
'chutney-green-light': '#3aa85e',
'chutney-red': '#c0392b',
'warm-grey': '#d4c4a0',
saffron: '#ff9933',
},
fontFamily: {
baloo: ['var(--font-baloo)', 'Baloo 2', 'cursive'],
vibes: ['var(--font-vibes)', 'Great Vibes', 'cursive'],
caveat: ['var(--font-caveat)', 'Caveat', 'cursive'],
sans: ['var(--font-inter)', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['Monaco', 'Menlo', 'Consolas', 'monospace'],
},
keyframes: {
pendulum: {
'0%': { transform: 'translateX(-50%) rotate(-4deg)' },
'100%': { transform: 'translateX(-50%) rotate(4deg)' },
},
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-8px)' },
},
wobble: {
'0%, 100%': { transform: 'rotate(-1.5deg)' },
'50%': { transform: 'rotate(1.5deg)' },
},
steamFloat: {
'0%': { opacity: '0', transform: 'translateY(4px) scaleX(0.8)' },
'35%': { opacity: '0.5' },
'70%': { opacity: '0.35' },
'100%': { opacity: '0', transform: 'translateY(-18px) scaleX(1.4)' },
},
steamType: {
'0%, 100%': { opacity: '0.25', transform: 'scaleY(0.6)' },
'50%': { opacity: '0.8', transform: 'scaleY(1)' },
},
fadeIn: {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
pendulum: 'pendulum 3s ease-in-out infinite alternate',
float: 'float 2.5s ease-in-out infinite',
wobble: 'wobble 3s ease-in-out infinite',
'steam-1': 'steamFloat 2.8s ease-in-out infinite',
'steam-2': 'steamFloat 2.8s ease-in-out 0.7s infinite',
'steam-3': 'steamFloat 2.8s ease-in-out 1.4s infinite',
'steam-type': 'steamType 1.6s ease-in-out infinite',
'fade-in': 'fadeIn 0.35s ease-out both',
},
},
},
plugins: [],
};
export default config;

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,24 @@
export type Role = 'user' | 'assistant' | 'system' | 'console';
export interface Message {
id: string;
role: Role;
content: string;
createdAt: number;
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
}
export interface ModelOption {
id: string;
label: string;
description: string;
}
export type ConversationGroup = 'Today' | 'Yesterday' | 'Last 7 days' | 'Older';