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({
|
const chats = await prisma.chat.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
|
||||||
include: {
|
include: {
|
||||||
messages: {
|
messages: {
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
|
|||||||
+199
-16
@@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
IconDotsVertical,
|
||||||
IconLayoutSidebar,
|
IconLayoutSidebar,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
|
IconPencil,
|
||||||
|
IconPin,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconRobot,
|
IconRobot,
|
||||||
IconSend,
|
IconSend,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconTrash,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +21,7 @@ import {
|
|||||||
Burger,
|
Burger,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
|
Menu,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Select,
|
Select,
|
||||||
@@ -35,10 +40,14 @@ import { useThemeContext } from '@/components/DynamicThemeProvider';
|
|||||||
import { SettingsModal } from '@/components/Settings/SettingsModal';
|
import { SettingsModal } from '@/components/Settings/SettingsModal';
|
||||||
import {
|
import {
|
||||||
addMessageToLocalChat,
|
addMessageToLocalChat,
|
||||||
|
deleteLocalChat,
|
||||||
|
deleteScrollPosition,
|
||||||
getLocalChat,
|
getLocalChat,
|
||||||
getLocalChats,
|
getLocalChats,
|
||||||
|
getScrollPosition,
|
||||||
mergeChats,
|
mergeChats,
|
||||||
saveLocalChat,
|
saveLocalChat,
|
||||||
|
saveScrollPosition,
|
||||||
setLocalChats,
|
setLocalChats,
|
||||||
} from '@/lib/chatStorage';
|
} from '@/lib/chatStorage';
|
||||||
import { MarkdownMessage } from './MarkdownMessage';
|
import { MarkdownMessage } from './MarkdownMessage';
|
||||||
@@ -55,6 +64,7 @@ interface Chat {
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
|
pinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputWithButton(props: TextInputProps) {
|
export function InputWithButton(props: TextInputProps) {
|
||||||
@@ -107,18 +117,24 @@ export default function ChatLayout() {
|
|||||||
const isUserScrolledUp = useRef(false);
|
const isUserScrolledUp = useRef(false);
|
||||||
const isStreaming = 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 = () => {
|
const handleScroll = () => {
|
||||||
if (isStreaming.current) {
|
|
||||||
return; // Ignore scroll position checks during streaming
|
|
||||||
}
|
|
||||||
const viewport = scrollViewportRef.current;
|
const viewport = scrollViewportRef.current;
|
||||||
if (!viewport) {
|
if (!viewport) {
|
||||||
return;
|
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;
|
const threshold = 50;
|
||||||
isUserScrolledUp.current =
|
isUserScrolledUp.current =
|
||||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > threshold;
|
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > threshold;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll to bottom using CSS scroll-behavior for smooth animation
|
// 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)
|
// Auto-scroll when messages change (during streaming)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (streamingMessageId) {
|
if (streamingMessageId) {
|
||||||
@@ -206,13 +240,9 @@ export default function ChatLayout() {
|
|||||||
if (mobileOpened) {
|
if (mobileOpened) {
|
||||||
toggleMobile();
|
toggleMobile();
|
||||||
}
|
}
|
||||||
// Scroll to bottom after messages load
|
|
||||||
setTimeout(() => {
|
// Restore saved scroll position
|
||||||
const viewport = scrollViewportRef.current;
|
restoreScrollPosition(chat.id);
|
||||||
if (viewport) {
|
|
||||||
viewport.scrollTop = viewport.scrollHeight;
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewChat = () => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -427,10 +564,10 @@ export default function ChatLayout() {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{chats.length > 0 ? (
|
{chats.length > 0 ? (
|
||||||
chats.map((chat) => (
|
chats.map((chat) => (
|
||||||
<UnstyledButton
|
<Group
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
onClick={() => handleSelectChat(chat)}
|
gap={0}
|
||||||
p="sm"
|
wrap="nowrap"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
@@ -440,13 +577,59 @@ export default function ChatLayout() {
|
|||||||
transition: 'background-color 0.2s',
|
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 }} />
|
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
|
||||||
<Text size="sm" truncate>
|
)}
|
||||||
|
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||||
{chat.title}
|
{chat.title}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</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">
|
<Text size="sm" c="dimmed" ta="center" mt="xl">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Chat {
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
pinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHATS_STORAGE_KEY = 'chat-gpz-chats';
|
const CHATS_STORAGE_KEY = 'chat-gpz-chats';
|
||||||
@@ -133,3 +134,72 @@ export function clearLocalChats(): void {
|
|||||||
}
|
}
|
||||||
localStorage.removeItem(CHATS_STORAGE_KEY);
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
title String
|
title String
|
||||||
|
pinned Boolean @default(false)
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
messages Message[]
|
messages Message[]
|
||||||
|
|||||||
Reference in New Issue
Block a user