mirror of
https://github.com/karpathy/nanochat.git
synced 2026-05-08 00:39:50 +00:00
Merge pull request #24 from manmohan659/feat/e2e-integration
feat(frontend): wire frontend to real backend auth + chat-api
This commit is contained in:
commit
bfa34a8a0e
32
.env.production.example
Normal file
32
.env.production.example
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Database (docker-compose postgres)
|
||||
DATABASE_URL=postgresql+asyncpg://samosachaat_admin:CHANGE_ME@postgres:5432/samosachaat
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
|
||||
# Auth service
|
||||
AUTH_BASE_URL=https://samosachaat.art/api
|
||||
FRONTEND_URL=https://samosachaat.art
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
JWT_PRIVATE_KEY=
|
||||
JWT_PUBLIC_KEY=
|
||||
INTERNAL_API_KEY=CHANGE_ME_RANDOM_STRING
|
||||
SESSION_SECRET=CHANGE_ME_RANDOM_STRING
|
||||
COOKIE_SECURE=true
|
||||
COOKIE_DOMAIN=samosachaat.art
|
||||
|
||||
# Chat API
|
||||
AUTH_SERVICE_URL=http://auth:8001
|
||||
INFERENCE_SERVICE_URL=http://inference:8003
|
||||
CHAT_API_URL=http://chat-api:8002
|
||||
|
||||
# Inference
|
||||
MODEL_STORAGE_PATH=/models
|
||||
DEFAULT_MODEL_TAG=samosachaat-d12
|
||||
NANOCHAT_DTYPE=float32
|
||||
HF_TOKEN=
|
||||
NUM_WORKERS=1
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_APP_URL=https://samosachaat.art
|
||||
15
scripts/generate-jwt-keys.sh
Executable file
15
scripts/generate-jwt-keys.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/bash
|
||||
# Generate RS256 JWT key pair for samosaChaat auth service
|
||||
set -e
|
||||
|
||||
openssl genrsa -out jwt_private.pem 2048
|
||||
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem
|
||||
|
||||
echo ""
|
||||
echo "Keys generated: jwt_private.pem, jwt_public.pem"
|
||||
echo ""
|
||||
echo "Add to your .env file:"
|
||||
echo "JWT_PRIVATE_KEY=\"$(cat jwt_private.pem | tr '\n' '|')\""
|
||||
echo "JWT_PUBLIC_KEY=\"$(cat jwt_public.pem | tr '\n' '|')\""
|
||||
echo ""
|
||||
echo "The | characters will be replaced back to newlines by the services."
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { handlers } from '@/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
|
|
@ -8,6 +8,7 @@ interface StreamBody {
|
|||
model?: string;
|
||||
temperature?: number;
|
||||
topK?: number;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
|
@ -16,10 +17,13 @@ function sseEvent(data: Record<string, unknown>) {
|
|||
return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
async function proxyUpstream(body: StreamBody, upstreamUrl: string) {
|
||||
async function proxyUpstream(body: StreamBody, upstreamUrl: string, authHeader: string | null) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
messages: body.messages,
|
||||
temperature: body.temperature ?? 0.8,
|
||||
|
|
@ -83,9 +87,25 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
|
||||
const upstream = process.env.CHAT_API_URL;
|
||||
const authHeader = req.headers.get('authorization');
|
||||
|
||||
if (upstream) {
|
||||
try {
|
||||
return await proxyUpstream(body, `${upstream.replace(/\/$/, '')}/chat/completions`);
|
||||
// If we have a conversationId and auth, use the persisted messages endpoint
|
||||
const convId = body.conversationId;
|
||||
if (convId && authHeader) {
|
||||
return await proxyUpstream(
|
||||
body,
|
||||
`${upstream.replace(/\/$/, '')}/api/conversations/${convId}/messages`,
|
||||
authHeader,
|
||||
);
|
||||
}
|
||||
// Fallback to direct chat completions (no persistence)
|
||||
return await proxyUpstream(
|
||||
body,
|
||||
`${upstream.replace(/\/$/, '')}/chat/completions`,
|
||||
authHeader,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[chat/stream] upstream failed, falling back to mock:', err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CHAT_API = process.env.CHAT_API_URL || 'http://chat-api:8002';
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
return new Response(`Backend error: ${res.status}`, { status: res.status });
|
||||
}
|
||||
|
||||
// Stream SSE through
|
||||
return new Response(res.body, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[conversations/:id/messages] POST error:', err);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
54
services/frontend/app/api/conversations/[id]/route.ts
Normal file
54
services/frontend/app/api/conversations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const CHAT_API = process.env.CHAT_API_URL || 'http://chat-api:8002';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}`, {
|
||||
headers: { Authorization: auth },
|
||||
});
|
||||
return NextResponse.json(await res.json(), { status: res.status });
|
||||
} catch (err) {
|
||||
console.error('[conversations/:id] GET error:', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch conversation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
try {
|
||||
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(await res.json(), { status: res.status });
|
||||
} catch (err) {
|
||||
console.error('[conversations/:id] PUT error:', err);
|
||||
return NextResponse.json({ error: 'Failed to update conversation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: auth },
|
||||
});
|
||||
return NextResponse.json({ ok: true }, { status: res.status });
|
||||
} catch (err) {
|
||||
console.error('[conversations/:id] DELETE error:', err);
|
||||
return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,44 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const now = () => Date.now();
|
||||
const CHAT_API = process.env.CHAT_API_URL || 'http://chat-api:8002';
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
function getAuthHeader(req: NextRequest): string | null {
|
||||
return req.headers.get('authorization');
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = getAuthHeader(req);
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CHAT_API}/api/conversations`, {
|
||||
headers: { Authorization: auth, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (err) {
|
||||
console.error('[conversations] proxy error:', err);
|
||||
return NextResponse.json({ conversations: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const auth = getAuthHeader(req);
|
||||
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
try {
|
||||
const res = await fetch(`${CHAT_API}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (err) {
|
||||
console.error('[conversations] create error:', err);
|
||||
return NextResponse.json({ error: 'Failed to create conversation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useAuth, useTokenCapture } from '@/hooks/useAuth';
|
||||
import Sidebar from '@/components/chat/Sidebar';
|
||||
import ChatWindow from '@/components/chat/ChatWindow';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const metadata = { title: 'Chat — samosaChaat' };
|
||||
function ChatContent() {
|
||||
useTokenCapture();
|
||||
const { authenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-dvh items-center justify-center">Loading...</div>;
|
||||
}
|
||||
if (!authenticated) {
|
||||
redirect('/login');
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
<main className="flex h-dvh overflow-hidden">
|
||||
<Sidebar />
|
||||
|
|
@ -11,3 +25,11 @@ export default function ChatPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-dvh items-center justify-center">Loading...</div>}>
|
||||
<ChatContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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({
|
||||
|
|
@ -47,7 +46,7 @@ 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>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
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' };
|
||||
function LoginContent() {
|
||||
const { authenticated, loading } = useAuth();
|
||||
|
||||
if (loading) return null;
|
||||
if (authenticated) {
|
||||
redirect('/chat');
|
||||
return null;
|
||||
}
|
||||
|
||||
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">
|
||||
|
|
@ -30,9 +36,7 @@ export default async function LoginPage() {
|
|||
</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<Suspense fallback={<div className="h-24" aria-hidden="true" />}>
|
||||
<OAuthButtons />
|
||||
</Suspense>
|
||||
<OAuthButtons />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 my-6" aria-hidden="true">
|
||||
|
|
@ -76,3 +80,11 @@ export default async function LoginPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-dvh items-center justify-center">Loading...</div>}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,4 @@
|
|||
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;
|
||||
},
|
||||
},
|
||||
});
|
||||
// NextAuth has been removed.
|
||||
// Auth is now handled by the backend auth service (OAuth + JWT).
|
||||
// See lib/auth-client.ts and hooks/useAuth.ts for the client-side token helpers.
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import ToranSvg from './svg/ToranSvg';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export default function LandingNav() {
|
||||
const { authenticated } = useAuth();
|
||||
|
||||
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">
|
||||
|
|
@ -35,12 +40,21 @@ export default function LandingNav() {
|
|||
>
|
||||
@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>
|
||||
{authenticated ? (
|
||||
<Link
|
||||
href="/chat"
|
||||
className="px-3 py-1 rounded-full border border-warm-grey text-brown bg-cream-light hover:bg-cream transition-colors"
|
||||
>
|
||||
Chat
|
||||
</Link>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function SessionBoundary({ children }: { children: ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
// SessionProvider (NextAuth) has been removed.
|
||||
// This file is kept as a no-op for any residual imports.
|
||||
export default function SessionBoundary({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
'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 { useAuth } from '@/hooks/useAuth';
|
||||
import { authHeaders } from '@/lib/auth-client';
|
||||
import { parseSlashCommand } from '@/lib/slashCommands';
|
||||
import type { Message } from '@/types/chat';
|
||||
|
||||
export default function ChatWindow() {
|
||||
const { data: session } = useSession();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
conversations,
|
||||
currentConversationId,
|
||||
|
|
@ -21,6 +22,7 @@ export default function ChatWindow() {
|
|||
topK,
|
||||
sidebarOpen,
|
||||
toggleSidebar,
|
||||
createConversation,
|
||||
newConversation,
|
||||
appendMessage,
|
||||
updateMessage,
|
||||
|
|
@ -67,7 +69,7 @@ export default function ChatWindow() {
|
|||
updateMessage(
|
||||
currentConversationId,
|
||||
streamingMsgId,
|
||||
`⚠️ Error: ${err.message}. Using mock responses requires only the frontend; cloud streaming requires CHAT_API_URL.`,
|
||||
`Error: ${err.message}. Using mock responses requires only the frontend; cloud streaming requires CHAT_API_URL.`,
|
||||
);
|
||||
}
|
||||
setStreamingMsgId(null);
|
||||
|
|
@ -75,17 +77,21 @@ export default function ChatWindow() {
|
|||
},
|
||||
});
|
||||
|
||||
const ensureConversation = useCallback(() => {
|
||||
const ensureConversation = useCallback(async () => {
|
||||
if (currentConversationId) return currentConversationId;
|
||||
// Try creating via the API first
|
||||
const apiId = await createConversation();
|
||||
if (apiId) return apiId;
|
||||
// Fallback to local-only conversation (mock mode)
|
||||
return newConversation();
|
||||
}, [currentConversationId, newConversation]);
|
||||
}, [currentConversationId, createConversation, newConversation]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (rawInput?: string) => {
|
||||
const text = (rawInput ?? draft).trim();
|
||||
if (!text || isStreaming) return;
|
||||
|
||||
const convId = ensureConversation();
|
||||
const convId = await ensureConversation();
|
||||
|
||||
const slash = parseSlashCommand(text, { temperature, topK });
|
||||
if (slash.handled) {
|
||||
|
|
@ -93,7 +99,8 @@ export default function ChatWindow() {
|
|||
if (slash.setTemperature !== undefined) setTemperature(slash.setTemperature);
|
||||
if (slash.setTopK !== undefined) setTopK(slash.setTopK);
|
||||
if (slash.clear) {
|
||||
newConversation();
|
||||
const apiId = await createConversation();
|
||||
if (!apiId) newConversation();
|
||||
return;
|
||||
}
|
||||
if (slash.consoleMessage) {
|
||||
|
|
@ -116,7 +123,14 @@ export default function ChatWindow() {
|
|||
.slice(0, -1)
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
|
||||
await start({ messages: history, model, temperature, topK });
|
||||
await start({
|
||||
messages: history,
|
||||
model,
|
||||
temperature,
|
||||
topK,
|
||||
conversationId: convId,
|
||||
auth: authHeaders(),
|
||||
});
|
||||
},
|
||||
[
|
||||
draft,
|
||||
|
|
@ -128,6 +142,7 @@ export default function ChatWindow() {
|
|||
model,
|
||||
setTemperature,
|
||||
setTopK,
|
||||
createConversation,
|
||||
newConversation,
|
||||
start,
|
||||
],
|
||||
|
|
@ -153,7 +168,7 @@ export default function ChatWindow() {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{session?.user?.name ? `Hi, ${session.user.name.split(' ')[0]}` : ''}
|
||||
{user?.name ? `Hi, ${user.name.split(' ')[0]}` : ''}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
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 { Plus, PanelLeftClose, PanelLeftOpen, LogOut, ChevronDown, Trash2 } from 'lucide-react';
|
||||
import SamosaLogo from '@/components/svg/SamosaLogo';
|
||||
import { useChatStore, groupConversations, MODEL_OPTIONS } from '@/store/chatStore';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { data: session } = useSession();
|
||||
const { user, logout } = useAuth();
|
||||
const {
|
||||
conversations,
|
||||
currentConversationId,
|
||||
|
|
@ -17,14 +17,15 @@ export default function Sidebar() {
|
|||
model,
|
||||
setModel,
|
||||
toggleSidebar,
|
||||
newConversation,
|
||||
createConversation,
|
||||
selectConversation,
|
||||
hydrateMockConversations,
|
||||
deleteConversation,
|
||||
fetchConversations,
|
||||
} = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
hydrateMockConversations();
|
||||
}, [hydrateMockConversations]);
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
const grouped = groupConversations(conversations);
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ export default function Sidebar() {
|
|||
<div className="px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => newConversation()}
|
||||
onClick={() => createConversation()}
|
||||
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" />
|
||||
|
|
@ -72,12 +73,12 @@ export default function Sidebar() {
|
|||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{items.map((c) => (
|
||||
<li key={c.id}>
|
||||
<li key={c.id} className="group relative">
|
||||
<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',
|
||||
'w-full text-left px-2.5 py-1.5 rounded text-sm truncate transition-colors pr-8',
|
||||
c.id === currentConversationId
|
||||
? 'bg-cream text-brown font-medium'
|
||||
: 'text-gray-700 hover:bg-cream/70',
|
||||
|
|
@ -86,6 +87,17 @@ export default function Sidebar() {
|
|||
>
|
||||
{c.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteConversation(c.id);
|
||||
}}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-cream text-gray-400 hover:text-chutney-red transition-all"
|
||||
aria-label={`Delete ${c.title}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -121,20 +133,20 @@ export default function Sidebar() {
|
|||
|
||||
<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()}
|
||||
{(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'}
|
||||
{user?.name ?? 'Guest'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{session?.user?.email ?? 'Not signed in'}
|
||||
{user?.email ?? 'Not signed in'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sign out"
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
onClick={logout}
|
||||
className="p-1.5 rounded hover:bg-cream text-gray-500 hover:text-brown"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export default function Hero() {
|
||||
const { status } = useSession();
|
||||
const ctaHref = status === 'authenticated' ? '/chat' : '/login';
|
||||
const { authenticated } = useAuth();
|
||||
const ctaHref = authenticated ? '/chat' : '/login';
|
||||
|
||||
return (
|
||||
<section className="relative z-[2] text-center px-4 pt-6">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
'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">
|
||||
|
|
@ -27,40 +23,23 @@ function GitHubIcon() {
|
|||
}
|
||||
|
||||
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"
|
||||
<a
|
||||
href="/api/auth/google"
|
||||
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 transition-colors font-sans text-sm font-medium text-gray-700"
|
||||
>
|
||||
<GoogleIcon />
|
||||
<span>{busy === 'google' ? 'Redirecting…' : 'Continue with Google'}</span>
|
||||
</button>
|
||||
<span>Continue with Google</span>
|
||||
</a>
|
||||
|
||||
<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"
|
||||
<a
|
||||
href="/api/auth/github"
|
||||
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 transition-colors font-sans text-sm font-medium text-gray-700"
|
||||
>
|
||||
<GitHubIcon />
|
||||
<span>{busy === 'github' ? 'Redirecting…' : 'Continue with GitHub'}</span>
|
||||
</button>
|
||||
<span>Continue with GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
services/frontend/hooks/useAuth.ts
Normal file
49
services/frontend/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
getToken,
|
||||
setToken,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
getUser,
|
||||
type TokenUser,
|
||||
} from '@/lib/auth-client';
|
||||
|
||||
export function useAuth() {
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<TokenUser | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setAuthenticated(isAuthenticated());
|
||||
setUser(getUser());
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
clearToken();
|
||||
setAuthenticated(false);
|
||||
setUser(null);
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return { authenticated, loading, user, logout };
|
||||
}
|
||||
|
||||
/** Hook to capture access_token from the OAuth redirect query param */
|
||||
export function useTokenCapture() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('access_token');
|
||||
if (token) {
|
||||
setToken(token);
|
||||
// Remove token from URL for cleanliness / security
|
||||
router.replace('/chat');
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ export interface StreamRequest {
|
|||
model?: string;
|
||||
temperature?: number;
|
||||
topK?: number;
|
||||
conversationId?: string;
|
||||
auth?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useSSE(endpoint: string, options: SSEOptions = {}) {
|
||||
|
|
@ -33,10 +35,21 @@ export function useSSE(endpoint: string, options: SSEOptions = {}) {
|
|||
setIsStreaming(true);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (body.auth) {
|
||||
Object.assign(headers, body.auth);
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
messages: body.messages,
|
||||
model: body.model,
|
||||
temperature: body.temperature,
|
||||
topK: body.topK,
|
||||
conversationId: body.conversationId,
|
||||
}),
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
|
|
|
|||
52
services/frontend/lib/auth-client.ts
Normal file
52
services/frontend/lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const TOKEN_KEY = 'samosachaat_access_token';
|
||||
const USER_KEY = 'samosachaat_user';
|
||||
|
||||
export interface TokenUser {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
// Decode JWT payload to persist basic user info
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const user: TokenUser = {
|
||||
name: payload.name || payload.email || 'User',
|
||||
email: payload.email || '',
|
||||
};
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} catch {
|
||||
/* malformed JWT — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
export function getUser(): TokenUser | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as TokenUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
if (!token) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
|
@ -1,19 +1,12 @@
|
|||
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);
|
||||
}
|
||||
export function middleware() {
|
||||
// Auth is checked client-side via useAuth / localStorage.
|
||||
// Middleware is intentionally a pass-through so the
|
||||
// /chat?access_token=... redirect from the auth service works.
|
||||
return NextResponse.next();
|
||||
});
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/chat/:path*'],
|
||||
matcher: [], // No server-side auth blocking
|
||||
};
|
||||
|
|
|
|||
129
services/frontend/package-lock.json
generated
129
services/frontend/package-lock.json
generated
|
|
@ -13,7 +13,6 @@
|
|||
"highlight.js": "^11.10.0",
|
||||
"lucide-react": "^0.451.0",
|
||||
"next": "14.2.15",
|
||||
"next-auth": "5.0.0-beta.22",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
|
|
@ -46,37 +45,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.35.3.tgz",
|
||||
"integrity": "sha512-g6qfiqU4OtyvIEZ8J7UoIwAxEnNnLJV0/f/DW41U+4G5nhBlaCrnKhawJIJpU0D3uavXLeDT3B0BkjtiimvMDA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@panva/hkdf": "^1.1.1",
|
||||
"@types/cookie": "0.6.0",
|
||||
"cookie": "0.6.0",
|
||||
"jose": "^5.1.3",
|
||||
"oauth4webapi": "^2.10.4",
|
||||
"preact": "10.11.3",
|
||||
"preact-render-to-string": "5.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"nodemailer": "^6.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
|
|
@ -519,15 +487,6 @@
|
|||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
|
|
@ -580,12 +539,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
|
|
@ -1970,15 +1923,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -4154,15 +4098,6 @@
|
|||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -5419,33 +5354,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-auth": {
|
||||
"version": "5.0.0-beta.22",
|
||||
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.22.tgz",
|
||||
"integrity": "sha512-QGBo9HGOjmnJBHGXvtFztl0tM5tL0porDlk74HVoCCzXd986ApOlIW3EmiCuho7YzEopgkFiwwmcXpoCrHAtYw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@auth/core": "0.35.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"next": "^14.0.0-0 || ^15.0.0-0",
|
||||
"nodemailer": "^6.6.5",
|
||||
"react": "^18.2.0 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
|
@ -5520,15 +5428,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
|
||||
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -6066,28 +5965,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.11.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
|
||||
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/preact-render-to-string": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
|
||||
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pretty-format": "^3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -6098,12 +5975,6 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
|
|||
|
|
@ -10,28 +10,27 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.11.8",
|
||||
"highlight.js": "^11.10.0",
|
||||
"lucide-react": "^0.451.0",
|
||||
"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"
|
||||
"remark-gfm": "^4.0.0",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.10",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "14.2.15",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "14.2.15"
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { authHeaders } from '@/lib/auth-client';
|
||||
import type { Conversation, Message, ModelOption } from '@/types/chat';
|
||||
|
||||
const uid = () => Math.random().toString(36).slice(2, 10);
|
||||
const now = () => Date.now();
|
||||
|
||||
export const MODEL_OPTIONS: ModelOption[] = [
|
||||
{ id: 'nanochat-base', label: 'nanochat · base', description: 'Default cloud model' },
|
||||
|
|
@ -12,65 +14,44 @@ export const MODEL_OPTIONS: ModelOption[] = [
|
|||
{ id: 'nanochat-local', label: 'nanochat · local (WebGPU)', description: 'Browser GPU (experimental)' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* State shape */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ChatState {
|
||||
/* data */
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
|
||||
/* settings (persisted in localStorage) */
|
||||
model: string;
|
||||
temperature: number;
|
||||
topK: number;
|
||||
sidebarOpen: boolean;
|
||||
|
||||
/* setting mutators */
|
||||
setModel: (m: string) => void;
|
||||
setTemperature: (t: number) => void;
|
||||
setTopK: (k: number) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
newConversation: () => string;
|
||||
/* API-backed actions */
|
||||
fetchConversations: () => Promise<void>;
|
||||
fetchMessages: (conversationId: string) => Promise<void>;
|
||||
createConversation: (title?: string) => Promise<string | null>;
|
||||
deleteConversation: (id: string) => Promise<void>;
|
||||
selectConversation: (id: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
|
||||
/* local-only helpers (optimistic UI) */
|
||||
newConversation: () => string;
|
||||
appendMessage: (conversationId: string, message: Omit<Message, 'id' | 'createdAt'>) => string;
|
||||
updateMessage: (conversationId: string, messageId: string, content: string) => void;
|
||||
setConversationTitle: (id: string, title: string) => void;
|
||||
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,
|
||||
},
|
||||
];
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Store */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
|
|
@ -87,6 +68,109 @@ export const useChatStore = create<ChatState>()(
|
|||
setTopK: (k) => set({ topK: k }),
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
|
||||
/* ---- API actions ---- */
|
||||
|
||||
fetchConversations: async () => {
|
||||
try {
|
||||
const res = await fetch('/api/conversations', {
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const list: Conversation[] = (data.conversations ?? []).map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(c: any) => ({
|
||||
id: c.id,
|
||||
title: c.title ?? 'New chat',
|
||||
messages: c.messages ?? [],
|
||||
createdAt: c.createdAt ? new Date(c.createdAt).getTime() : now(),
|
||||
updatedAt: c.updatedAt ? new Date(c.updatedAt).getTime() : now(),
|
||||
}),
|
||||
);
|
||||
set({ conversations: list });
|
||||
} catch (err) {
|
||||
console.error('[chatStore] fetchConversations error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
fetchMessages: async (conversationId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${conversationId}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messages: Message[] = (data.messages ?? []).map((m: any) => ({
|
||||
id: m.id ?? uid(),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
createdAt: m.createdAt ? new Date(m.createdAt).getTime() : now(),
|
||||
}));
|
||||
set((s) => ({
|
||||
conversations: s.conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, messages } : c,
|
||||
),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[chatStore] fetchMessages error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
createConversation: async (title) => {
|
||||
try {
|
||||
const res = await fetch('/api/conversations', {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title ?? 'New chat' }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const conv: Conversation = {
|
||||
id: data.id,
|
||||
title: data.title ?? title ?? 'New chat',
|
||||
messages: [],
|
||||
createdAt: data.createdAt ? new Date(data.createdAt).getTime() : now(),
|
||||
updatedAt: data.updatedAt ? new Date(data.updatedAt).getTime() : now(),
|
||||
};
|
||||
set((s) => ({
|
||||
conversations: [conv, ...s.conversations],
|
||||
currentConversationId: conv.id,
|
||||
}));
|
||||
return conv.id;
|
||||
} catch (err) {
|
||||
console.error('[chatStore] createConversation error:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteConversation: async (id) => {
|
||||
try {
|
||||
await fetch(`/api/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[chatStore] deleteConversation error:', err);
|
||||
}
|
||||
set((s) => {
|
||||
const rest = s.conversations.filter((c) => c.id !== id);
|
||||
return {
|
||||
conversations: rest,
|
||||
currentConversationId:
|
||||
s.currentConversationId === id ? rest[0]?.id ?? null : s.currentConversationId,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
selectConversation: (id) => {
|
||||
set({ currentConversationId: id });
|
||||
// Fetch latest messages for the selected conversation
|
||||
get().fetchMessages(id);
|
||||
},
|
||||
|
||||
/* ---- local-only helpers (for optimistic UI & mock mode) ---- */
|
||||
|
||||
newConversation: () => {
|
||||
const id = uid();
|
||||
const conv: Conversation = {
|
||||
|
|
@ -100,17 +184,6 @@ export const useChatStore = create<ChatState>()(
|
|||
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) => ({
|
||||
|
|
@ -120,7 +193,10 @@ export const useChatStore = create<ChatState>()(
|
|||
...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,
|
||||
title:
|
||||
c.title === 'New chat' && message.role === 'user'
|
||||
? message.content.slice(0, 48)
|
||||
: c.title,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
|
|
@ -134,7 +210,9 @@ export const useChatStore = create<ChatState>()(
|
|||
c.id === conversationId
|
||||
? {
|
||||
...c,
|
||||
messages: c.messages.map((m) => (m.id === messageId ? { ...m, content } : m)),
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === messageId ? { ...m, content } : m,
|
||||
),
|
||||
updatedAt: now(),
|
||||
}
|
||||
: c,
|
||||
|
|
@ -145,27 +223,24 @@ export const useChatStore = create<ChatState>()(
|
|||
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',
|
||||
name: 'samosachaat-settings',
|
||||
// Only persist user preferences — conversations live in the DB
|
||||
partialize: (s) => ({
|
||||
conversations: s.conversations,
|
||||
currentConversationId: s.currentConversationId,
|
||||
model: s.model,
|
||||
temperature: s.temperature,
|
||||
topK: s.topK,
|
||||
sidebarOpen: s.sidebarOpen,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Grouping helper (unchanged) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function groupConversations(conversations: Conversation[]) {
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const t = now();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user