changes
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || 'your-secret-key-at-least-32-chars-long'
|
||||
);
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// PATCH /api/chats/[id] - Update chat (rename, pin)
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const token = request.cookies.get('token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
const userId = payload.userId as string;
|
||||
const { id: chatId } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Verify chat belongs to user
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: { id: chatId, userId },
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
return NextResponse.json({ error: 'Chat not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: { title?: string; pinned?: boolean } = {};
|
||||
if (typeof body.title === 'string') {
|
||||
updateData.title = body.title;
|
||||
}
|
||||
if (typeof body.pinned === 'boolean') {
|
||||
updateData.pinned = body.pinned;
|
||||
}
|
||||
|
||||
const updatedChat = await prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedChat, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Update chat error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/chats/[id] - Delete chat
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const token = request.cookies.get('token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
const userId = payload.userId as string;
|
||||
const { id: chatId } = await params;
|
||||
|
||||
// Verify chat belongs to user
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: { id: chatId, userId },
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
return NextResponse.json({ error: 'Chat not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete messages first (cascade), then chat
|
||||
await prisma.message.deleteMany({
|
||||
where: { chatId },
|
||||
});
|
||||
|
||||
await prisma.chat.delete({
|
||||
where: { id: chatId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Delete chat error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
include: {
|
||||
messages: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
|
||||
+199
-16
@@ -2,12 +2,16 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconLayoutSidebar,
|
||||
IconMessage,
|
||||
IconPencil,
|
||||
IconPin,
|
||||
IconPlus,
|
||||
IconRobot,
|
||||
IconSend,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
Burger,
|
||||
Container,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
@@ -35,10 +40,14 @@ import { useThemeContext } from '@/components/DynamicThemeProvider';
|
||||
import { SettingsModal } from '@/components/Settings/SettingsModal';
|
||||
import {
|
||||
addMessageToLocalChat,
|
||||
deleteLocalChat,
|
||||
deleteScrollPosition,
|
||||
getLocalChat,
|
||||
getLocalChats,
|
||||
getScrollPosition,
|
||||
mergeChats,
|
||||
saveLocalChat,
|
||||
saveScrollPosition,
|
||||
setLocalChats,
|
||||
} from '@/lib/chatStorage';
|
||||
import { MarkdownMessage } from './MarkdownMessage';
|
||||
@@ -55,6 +64,7 @@ interface Chat {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
messages?: Message[];
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export function InputWithButton(props: TextInputProps) {
|
||||
@@ -107,18 +117,24 @@ export default function ChatLayout() {
|
||||
const isUserScrolledUp = useRef(false);
|
||||
const isStreaming = useRef(false);
|
||||
|
||||
// Handle scroll events to track if user scrolled up (only when not streaming)
|
||||
// Handle scroll events - save position and track if user scrolled up
|
||||
const handleScroll = () => {
|
||||
if (isStreaming.current) {
|
||||
return; // Ignore scroll position checks during streaming
|
||||
}
|
||||
const viewport = scrollViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save scroll position for current chat
|
||||
if (activeChatId) {
|
||||
saveScrollPosition(activeChatId, viewport.scrollTop);
|
||||
}
|
||||
|
||||
// Track if user scrolled up (only when not streaming)
|
||||
if (!isStreaming.current) {
|
||||
const threshold = 50;
|
||||
isUserScrolledUp.current =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > threshold;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll to bottom using CSS scroll-behavior for smooth animation
|
||||
@@ -129,6 +145,24 @@ export default function ChatLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Restore scroll position for a chat
|
||||
const restoreScrollPosition = (chatId: string) => {
|
||||
setTimeout(() => {
|
||||
const viewport = scrollViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedPosition = getScrollPosition(chatId);
|
||||
if (savedPosition !== null) {
|
||||
viewport.scrollTop = savedPosition;
|
||||
} else {
|
||||
// New chat or no saved position - scroll to bottom
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Auto-scroll when messages change (during streaming)
|
||||
useEffect(() => {
|
||||
if (streamingMessageId) {
|
||||
@@ -206,13 +240,9 @@ export default function ChatLayout() {
|
||||
if (mobileOpened) {
|
||||
toggleMobile();
|
||||
}
|
||||
// Scroll to bottom after messages load
|
||||
setTimeout(() => {
|
||||
const viewport = scrollViewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Restore saved scroll position
|
||||
restoreScrollPosition(chat.id);
|
||||
};
|
||||
|
||||
const handleNewChat = () => {
|
||||
@@ -367,6 +397,113 @@ export default function ChatLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Chat menu handlers
|
||||
const handleRenameChat = async (chatId: string) => {
|
||||
const chat = chats.find((c) => c.id === chatId);
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTitle = window.prompt('Enter new chat title:', chat.title);
|
||||
if (!newTitle || newTitle === chat.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update in database
|
||||
await fetch(`/api/chats/${chatId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setChats((prev) => prev.map((c) => (c.id === chatId ? { ...c, title: newTitle } : c)));
|
||||
|
||||
// Update localStorage
|
||||
const localChat = getLocalChat(chatId);
|
||||
if (localChat) {
|
||||
saveLocalChat({ ...localChat, title: newTitle });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to rename chat:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinChat = async (chatId: string) => {
|
||||
const chat = chats.find((c) => c.id === chatId);
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPinned = !chat.pinned;
|
||||
|
||||
try {
|
||||
// Update in database
|
||||
await fetch(`/api/chats/${chatId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pinned: newPinned }),
|
||||
});
|
||||
|
||||
// Update local state and re-sort (pinned first)
|
||||
setChats((prev) => {
|
||||
const updated = prev.map((c) => (c.id === chatId ? { ...c, pinned: newPinned } : c));
|
||||
return updated.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.pinned && b.pinned) {
|
||||
return 1;
|
||||
}
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
// Update localStorage
|
||||
const localChat = getLocalChat(chatId);
|
||||
if (localChat) {
|
||||
saveLocalChat({ ...localChat, pinned: newPinned });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to pin chat:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveChat = async (chatId: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this chat?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete from database
|
||||
await fetch(`/api/chats/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// Remove from local state
|
||||
setChats((prev) => prev.filter((c) => c.id !== chatId));
|
||||
|
||||
// Remove from localStorage
|
||||
deleteLocalChat(chatId);
|
||||
deleteScrollPosition(chatId);
|
||||
|
||||
// If this was the active chat, clear messages
|
||||
if (activeChatId === chatId) {
|
||||
setActiveChatId(null);
|
||||
setMessages([
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: 'Hello! I am an AI assistant. How can I help you today?',
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete chat:', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell
|
||||
@@ -427,10 +564,10 @@ export default function ChatLayout() {
|
||||
<Stack gap="xs">
|
||||
{chats.length > 0 ? (
|
||||
chats.map((chat) => (
|
||||
<UnstyledButton
|
||||
<Group
|
||||
key={chat.id}
|
||||
onClick={() => handleSelectChat(chat)}
|
||||
p="sm"
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
backgroundColor:
|
||||
@@ -440,13 +577,59 @@ export default function ChatLayout() {
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<UnstyledButton
|
||||
onClick={() => handleSelectChat(chat)}
|
||||
p="sm"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
{chat.pinned ? (
|
||||
<IconPin size={18} color="gray" style={{ minWidth: 18 }} />
|
||||
) : (
|
||||
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
|
||||
<Text size="sm" truncate>
|
||||
)}
|
||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||
{chat.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
mr="xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={() => handleRenameChat(chat.id)}
|
||||
>
|
||||
Rename
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconPin size={14} />}
|
||||
onClick={() => handlePinChat(chat.id)}
|
||||
>
|
||||
{chat.pinned ? 'Unpin' : 'Pin'}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => handleRemoveChat(chat.id)}
|
||||
>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="xl">
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Chat {
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
messages: Message[];
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
const CHATS_STORAGE_KEY = 'chat-gpz-chats';
|
||||
@@ -133,3 +134,72 @@ export function clearLocalChats(): void {
|
||||
}
|
||||
localStorage.removeItem(CHATS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Scroll Position Storage
|
||||
// ============================================
|
||||
|
||||
const SCROLL_POSITIONS_KEY = 'chat-gpz-scroll-positions';
|
||||
|
||||
interface ScrollPositions {
|
||||
[chatId: string]: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored scroll positions
|
||||
*/
|
||||
function getScrollPositions(): ScrollPositions {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(SCROLL_POSITIONS_KEY);
|
||||
if (!stored) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(stored) as ScrollPositions;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save scroll positions to localStorage
|
||||
*/
|
||||
function setScrollPositions(positions: ScrollPositions): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(SCROLL_POSITIONS_KEY, JSON.stringify(positions));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scroll position for a specific chat
|
||||
* Returns null if no position saved (indicating should scroll to bottom for new chats)
|
||||
*/
|
||||
export function getScrollPosition(chatId: string): number | null {
|
||||
const positions = getScrollPositions();
|
||||
return positions[chatId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save scroll position for a specific chat
|
||||
*/
|
||||
export function saveScrollPosition(chatId: string, position: number): void {
|
||||
const positions = getScrollPositions();
|
||||
positions[chatId] = position;
|
||||
setScrollPositions(positions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete scroll position for a chat (when chat is deleted)
|
||||
*/
|
||||
export function deleteScrollPosition(chatId: string): void {
|
||||
const positions = getScrollPositions();
|
||||
delete positions[chatId];
|
||||
setScrollPositions(positions);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ model Chat {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
title String
|
||||
pinned Boolean @default(false)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
messages Message[]
|
||||
|
||||
Reference in New Issue
Block a user