mirror of
https://github.com/karpathy/nanochat.git
synced 2026-05-17 05:07:39 +00:00
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:
commit
9bd0c907cc
11
services/frontend/.dockerignore
Normal file
11
services/frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
npm-debug.log*
|
||||
coverage
|
||||
.DS_Store
|
||||
16
services/frontend/.env.example
Normal file
16
services/frontend/.env.example
Normal 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
13
services/frontend/.gitignore
vendored
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
70
services/frontend/README.md
Normal file
70
services/frontend/README.md
Normal 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.
|
||||
3
services/frontend/app/api/auth/[...nextauth]/route.ts
Normal file
3
services/frontend/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from '@/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
95
services/frontend/app/api/chat/stream/route.ts
Normal file
95
services/frontend/app/api/chat/stream/route.ts
Normal 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);
|
||||
}
|
||||
33
services/frontend/app/api/conversations/route.ts
Normal file
33
services/frontend/app/api/conversations/route.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
10
services/frontend/app/api/health/route.ts
Normal file
10
services/frontend/app/api/health/route.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
13
services/frontend/app/chat/page.tsx
Normal file
13
services/frontend/app/chat/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
services/frontend/app/globals.css
Normal file
50
services/frontend/app/globals.css
Normal 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; }
|
||||
54
services/frontend/app/layout.tsx
Normal file
54
services/frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
services/frontend/app/login/page.tsx
Normal file
78
services/frontend/app/login/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
19
services/frontend/app/page.tsx
Normal file
19
services/frontend/app/page.tsx
Normal 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
36
services/frontend/auth.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
20
services/frontend/components/LandingFooter.tsx
Normal file
20
services/frontend/components/LandingFooter.tsx
Normal 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>© 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>
|
||||
);
|
||||
}
|
||||
47
services/frontend/components/LandingNav.tsx
Normal file
47
services/frontend/components/LandingNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
services/frontend/components/SessionBoundary.tsx
Normal file
8
services/frontend/components/SessionBoundary.tsx
Normal 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>;
|
||||
}
|
||||
85
services/frontend/components/chat/ChatInput.tsx
Normal file
85
services/frontend/components/chat/ChatInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
services/frontend/components/chat/ChatWindow.tsx
Normal file
187
services/frontend/components/chat/ChatWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
services/frontend/components/chat/EmptyState.tsx
Normal file
39
services/frontend/components/chat/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
services/frontend/components/chat/MessageBubble.tsx
Normal file
107
services/frontend/components/chat/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
services/frontend/components/chat/Sidebar.tsx
Normal file
148
services/frontend/components/chat/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
services/frontend/components/landing/Hero.tsx
Normal file
58
services/frontend/components/landing/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
services/frontend/components/landing/Illustrations.tsx
Normal file
30
services/frontend/components/landing/Illustrations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
services/frontend/components/login/MandalaArt.tsx
Normal file
70
services/frontend/components/login/MandalaArt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
services/frontend/components/login/OAuthButtons.tsx
Normal file
66
services/frontend/components/login/OAuthButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
services/frontend/components/svg/Doodles.tsx
Normal file
33
services/frontend/components/svg/Doodles.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
services/frontend/components/svg/KettleSteam.tsx
Normal file
15
services/frontend/components/svg/KettleSteam.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
services/frontend/components/svg/KettleSvg.tsx
Normal file
35
services/frontend/components/svg/KettleSvg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
services/frontend/components/svg/SamosaLogo.tsx
Normal file
18
services/frontend/components/svg/SamosaLogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
services/frontend/components/svg/SamosaSvg.tsx
Normal file
34
services/frontend/components/svg/SamosaSvg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
services/frontend/components/svg/SteamTyping.tsx
Normal file
10
services/frontend/components/svg/SteamTyping.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
services/frontend/components/svg/ToranSvg.tsx
Normal file
20
services/frontend/components/svg/ToranSvg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
services/frontend/hooks/useSSE.ts
Normal file
93
services/frontend/hooks/useSSE.ts
Normal 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 };
|
||||
}
|
||||
49
services/frontend/lib/slashCommands.ts
Normal file
49
services/frontend/lib/slashCommands.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
19
services/frontend/middleware.ts
Normal file
19
services/frontend/middleware.ts
Normal 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
5
services/frontend/next-env.d.ts
vendored
Normal 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.
|
||||
16
services/frontend/next.config.mjs
Normal file
16
services/frontend/next.config.mjs
Normal 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
7960
services/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
services/frontend/package.json
Normal file
37
services/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
services/frontend/postcss.config.mjs
Normal file
6
services/frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
20
services/frontend/public/logo.svg
Normal file
20
services/frontend/public/logo.svg
Normal 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 |
191
services/frontend/store/chatStore.ts
Normal file
191
services/frontend/store/chatStore.ts
Normal 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;
|
||||
}
|
||||
74
services/frontend/tailwind.config.ts
Normal file
74
services/frontend/tailwind.config.ts
Normal 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;
|
||||
23
services/frontend/tsconfig.json
Normal file
23
services/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
24
services/frontend/types/chat.ts
Normal file
24
services/frontend/types/chat.ts
Normal 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';
|
||||
Loading…
Reference in New Issue
Block a user