From c85c73d867a355a4060cf207ab53e399f059ff48 Mon Sep 17 00:00:00 2001 From: Kowyo Bot Date: Mon, 2 Feb 2026 14:44:10 +0800 Subject: [PATCH 1/9] feat: persist user chat history --- .github/workflows/migrate.yml | 2 +- app/api/chat/route.ts | 49 ++++- app/api/chats/[chatId]/messages/route.ts | 45 +++++ app/api/chats/[chatId]/route.ts | 63 +++++++ app/api/chats/route.ts | 46 +++++ app/page.tsx | 221 +++++++++++++++-------- lib/db.ts | 21 +++ next-env.d.ts | 2 +- package.json | 2 + scripts/db/migrate-chats.js | 52 ++++++ 10 files changed, 427 insertions(+), 76 deletions(-) create mode 100644 app/api/chats/[chatId]/messages/route.ts create mode 100644 app/api/chats/[chatId]/route.ts create mode 100644 app/api/chats/route.ts create mode 100644 lib/db.ts create mode 100644 scripts/db/migrate-chats.js diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 2879321..120beea 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -32,4 +32,4 @@ jobs: run: pnpm install --frozen-lockfile - name: Run migrations - run: pnpm run db:migrate + run: pnpm run db:migrate:all diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index cc870eb..64a431d 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,11 +1,58 @@ import { streamText, UIMessage, convertToModelMessages } from 'ai'; +import { auth } from '@/lib/auth'; +import { getDbPool } from '@/lib/db'; + +function uiMessageToText(message: UIMessage) { + const parts = message.parts ?? []; + const text = parts + .filter((p: any) => p?.type === 'text') + .map((p: any) => p.text) + .join(''); + return text || (message as any).content || ''; +} export async function POST(req: Request) { - const { messages }: { messages: UIMessage[] } = await req.json(); + const session = await auth.api.getSession({ + headers: new Headers(req.headers), + }); + if (!session?.user?.id) { + return new Response('unauthorized', { status: 401 }); + } + + const { messages, chatId }: { messages: UIMessage[]; chatId?: string } = + await req.json(); + + // Persist the latest user message (best-effort) + if (chatId && messages?.length) { + const last = messages[messages.length - 1]; + if (last?.role === 'user') { + const pool = getDbPool(); + await pool.query( + `insert into chat_messages (chat_id, role, content) + values ($1, $2, $3)`, + [chatId, 'user', uiMessageToText(last)] + ); + await pool.query(`update chats set updated_at = now() where id = $1`, [ + chatId, + ]); + } + } const result = streamText({ model: 'anthropic/claude-haiku-4.5', messages: await convertToModelMessages(messages), + onFinish: async ({ text }) => { + if (!chatId) return; + const pool = getDbPool(); + await pool.query( + `insert into chat_messages (chat_id, role, content) + values ($1, $2, $3)`, + [chatId, 'assistant', text] + ); + await pool.query(`update chats set updated_at = now() where id = $1`, [ + chatId, + ]); + }, }); return result.toUIMessageStreamResponse(); diff --git a/app/api/chats/[chatId]/messages/route.ts b/app/api/chats/[chatId]/messages/route.ts new file mode 100644 index 0000000..f2dc26b --- /dev/null +++ b/app/api/chats/[chatId]/messages/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { getDbPool } from '@/lib/db'; + +export async function POST( + req: Request, + { params }: { params: Promise<{ chatId: string }> } +) { + const session = await auth.api.getSession({ + headers: new Headers(req.headers), + }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const { chatId } = await params; + const body = (await req.json()) as { role: string; content: string }; + const role = body.role; + const content = body.content; + if (!role || !content) { + return NextResponse.json({ error: 'bad_request' }, { status: 400 }); + } + + const pool = getDbPool(); + + // Ensure ownership + const chat = await pool.query( + `select id from chats where id = $1 and user_id = $2`, + [chatId, session.user.id] + ); + if (chat.rowCount === 0) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }); + } + + await pool.query( + `insert into chat_messages (chat_id, role, content) + values ($1, $2, $3)`, + [chatId, role, content] + ); + await pool.query(`update chats set updated_at = now() where id = $1`, [ + chatId, + ]); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/chats/[chatId]/route.ts b/app/api/chats/[chatId]/route.ts new file mode 100644 index 0000000..8e032cd --- /dev/null +++ b/app/api/chats/[chatId]/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { getDbPool } from '@/lib/db'; + +export async function GET( + req: Request, + { params }: { params: Promise<{ chatId: string }> } +) { + const session = await auth.api.getSession({ + headers: new Headers(req.headers), + }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const { chatId } = await params; + const pool = getDbPool(); + + // Ensure ownership + const chat = await pool.query( + `select id from chats where id = $1 and user_id = $2`, + [chatId, session.user.id] + ); + if (chat.rowCount === 0) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }); + } + + const { rows } = await pool.query( + `select id, role, content, created_at as "createdAt" + from chat_messages + where chat_id = $1 + order by created_at asc`, + [chatId] + ); + + return NextResponse.json({ messages: rows }); +} + +export async function DELETE( + req: Request, + { params }: { params: Promise<{ chatId: string }> } +) { + const session = await auth.api.getSession({ + headers: new Headers(req.headers), + }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const { chatId } = await params; + const pool = getDbPool(); + + const res = await pool.query( + `delete from chats where id = $1 and user_id = $2`, + [chatId, session.user.id] + ); + + if (res.rowCount === 0) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts new file mode 100644 index 0000000..0567497 --- /dev/null +++ b/app/api/chats/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { getDbPool } from '@/lib/db'; + +export async function GET(req: Request) { + const session = await auth.api.getSession({ + headers: new Headers(req.headers), + }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const pool = getDbPool(); + const { rows } = await pool.query( + `select id, title, created_at as "createdAt", updated_at as "updatedAt" + from chats + where user_id = $1 + order by updated_at desc + limit 50`, + [session.user.id] + ); + + return NextResponse.json({ chats: rows }); +} + +export async function POST(req: Request) { + const session = await auth.api.getSession({ + headers: new Headers(req.headers), + }); + if (!session?.user?.id) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const body = (await req.json().catch(() => ({}))) as { title?: string }; + const title = (body.title ?? 'New chat').slice(0, 200); + + const pool = getDbPool(); + const { rows } = await pool.query( + `insert into chats (user_id, title) + values ($1, $2) + returning id, title, created_at as "createdAt", updated_at as "updatedAt"`, + [session.user.id, title] + ); + + return NextResponse.json({ chat: rows[0] }); +} diff --git a/app/page.tsx b/app/page.tsx index e5ecfda..e0cf44b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Conversation, @@ -15,112 +15,184 @@ import { } from '@/components/ai-elements/message'; import { PromptInput, - PromptInputTextarea, PromptInputFooter, PromptInputSubmit, + PromptInputTextarea, } from '@/components/ai-elements/prompt-input'; -import { MessageSquare } from 'lucide-react'; -import { useChat } from '@ai-sdk/react'; -import { nanoid } from 'nanoid'; import { ChatSidebar, type Chat } from '@/components/chat-sidebar'; import { useSession } from '@/lib/auth-client'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import { MessageSquare } from 'lucide-react'; +import type { UIMessage } from 'ai'; -interface ChatSession { +type DbChat = { id: string; - messages: any[]; + title: string; + createdAt: string; + updatedAt: string; +}; + +type DbMessage = { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + createdAt: string; +}; + +function dbMessagesToUiMessages(rows: DbMessage[]): UIMessage[] { + return rows.map((m) => ({ + id: m.id, + role: m.role, + parts: [{ type: 'text', text: m.content }], + })); } -const ConversationDemo = () => { +export default function ConversationDemo() { const router = useRouter(); const { data: session, isPending } = useSession(); + const [chats, setChats] = useState([]); const [currentChatId, setCurrentChatId] = useState(null); - const [chatSessions, setChatSessions] = useState>( - {} - ); + const [loaded, setLoaded] = useState>(new Set()); + + const transport = useMemo(() => { + return new DefaultChatTransport({ + api: '/api/chat', + prepareSendMessagesRequest: ({ messages }) => ({ + body: { messages, chatId: currentChatId }, + }), + }); + }, [currentChatId]); - const { messages, sendMessage, status, setMessages } = useChat(); + const { messages, sendMessage, status, setMessages } = useChat({ transport }); + + const currentChatTitle = useMemo( + () => chats.find((c) => c.id === currentChatId)?.title, + [chats, currentChatId] + ); useEffect(() => { - if (!isPending && !session) { - router.push('/login'); - } + if (!isPending && !session) router.push('/login'); }, [session, isPending, router]); - const handleNewChat = () => { - const newChatId = nanoid(); - const newChat: Chat = { - id: newChatId, - title: `Chat ${chats.length + 1}`, - createdAt: new Date(), + useEffect(() => { + if (isPending || !session) return; + + (async () => { + const res = await fetch('/api/chats'); + if (!res.ok) return; + const data = (await res.json()) as { chats: DbChat[] }; + const nextChats: Chat[] = (data.chats ?? []).map((c) => ({ + id: c.id, + title: c.title, + createdAt: new Date(c.createdAt), + })); + setChats(nextChats); + + // Auto-select the most recent chat (if any) + if (nextChats.length > 0 && !currentChatId) { + setCurrentChatId(nextChats[0].id); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session, isPending]); + + // Load messages whenever current chat changes + useEffect(() => { + if (!session || !currentChatId) return; + + if (loaded.has(currentChatId)) return; + + (async () => { + const res = await fetch(`/api/chats/${currentChatId}`); + if (!res.ok) return; + const data = (await res.json()) as { messages: DbMessage[] }; + setMessages(dbMessagesToUiMessages(data.messages ?? [])); + setLoaded((prev) => new Set([...prev, currentChatId])); + })(); + }, [session, currentChatId, loaded, setMessages]); + + const handleNewChat = async () => { + const res = await fetch('/api/chats', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: `Chat ${chats.length + 1}` }), + }); + + if (!res.ok) return; + + const data = (await res.json()) as { chat: DbChat }; + const chat: Chat = { + id: data.chat.id, + title: data.chat.title, + createdAt: new Date(data.chat.createdAt), }; - setChats((prev) => [newChat, ...prev]); - setChatSessions((prev) => ({ - ...prev, - [newChatId]: { id: newChatId, messages: [] }, - })); - setCurrentChatId(newChatId); + setChats((prev) => [chat, ...prev]); + setCurrentChatId(chat.id); + setLoaded((prev) => { + const next = new Set(prev); + next.add(chat.id); + return next; + }); setMessages([]); }; - const handleChatSelect = (chatId: string) => { - const session = chatSessions[chatId]; - if (session) { - setCurrentChatId(chatId); - setMessages(session.messages); - } + const handleChatSelect = async (chatId: string) => { + setCurrentChatId(chatId); }; - const handleDeleteChat = (chatId: string) => { - setChats((prev) => prev.filter((chat) => chat.id !== chatId)); - setChatSessions((prev) => { - const updated = { ...prev }; - delete updated[chatId]; - return updated; + const handleDeleteChat = async (chatId: string) => { + await fetch(`/api/chats/${chatId}`, { method: 'DELETE' }); + + setChats((prev) => prev.filter((c) => c.id !== chatId)); + setLoaded((prev) => { + const next = new Set(prev); + next.delete(chatId); + return next; }); if (currentChatId === chatId) { - const remainingChats = chats.filter((chat) => chat.id !== chatId); - if (remainingChats.length > 0) { - const nextChat = remainingChats[0]; - setCurrentChatId(nextChat.id); - setMessages(chatSessions[nextChat.id]?.messages || []); - } else { - setCurrentChatId(null); - setMessages([]); - } + setCurrentChatId(null); + setMessages([]); } }; - const handleSendMessage = ({ text }: { text: string }) => { + const handleSendMessage = async ({ text }: { text: string }) => { if (!text.trim()) return; - if (!currentChatId) { - handleNewChat(); + let chatId = currentChatId; + if (!chatId) { + // Create on-demand + const res = await fetch('/api/chats', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: `Chat ${chats.length + 1}` }), + }); + if (!res.ok) return; + const data = (await res.json()) as { chat: DbChat }; + const chat: Chat = { + id: data.chat.id, + title: data.chat.title, + createdAt: new Date(data.chat.createdAt), + }; + setChats((prev) => [chat, ...prev]); + setCurrentChatId(chat.id); + setLoaded((prev) => { + const next = new Set(prev); + next.add(chat.id); + return next; + }); + chatId = chat.id; + setMessages([]); } + // transport will include { chatId } in the request body sendMessage({ text }); - - if (currentChatId) { - setTimeout(() => { - setChatSessions((prev) => ({ - ...prev, - [currentChatId]: { - ...prev[currentChatId], - messages: [ - ...(prev[currentChatId]?.messages || []), - { role: 'user', content: text }, - ], - }, - })); - }, 0); - } }; - if (isPending || !session) { - return null; - } + if (isPending || !session) return null; return ( { {messages.length === 0 ? ( } - title="Start a conversation" + title={ + currentChatTitle + ? `Start: ${currentChatTitle}` + : 'Start a conversation' + } description="Type a message below to begin chatting" /> ) : ( @@ -162,6 +238,7 @@ const ConversationDemo = () => { +
@@ -178,6 +255,4 @@ const ConversationDemo = () => {
); -}; - -export default ConversationDemo; +} diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..3a8ab25 --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,21 @@ +import { Pool } from 'pg'; + +declare global { + // eslint-disable-next-line no-var + var __dbPool: Pool | undefined; +} + +export function getDbPool() { + if (!globalThis.__dbPool) { + globalThis.__dbPool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: + process.env.DATABASE_URL?.includes('supabase.co') || + process.env.NODE_ENV === 'production' + ? { rejectUnauthorized: false } + : false, + }); + } + + return globalThis.__dbPool; +} diff --git a/next-env.d.ts b/next-env.d.ts index 20e7bcf..1511519 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/dev/types/routes.d.ts'; +import './.next/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index ba13a0e..3758a40 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "lint": "next lint", "format": "prettier --write .", "db:migrate": "npx @better-auth/cli migrate", + "db:migrate:chats": "node scripts/db/migrate-chats.js", + "db:migrate:all": "pnpm run db:migrate && pnpm run db:migrate:chats", "prepare": "husky" }, "dependencies": { diff --git a/scripts/db/migrate-chats.js b/scripts/db/migrate-chats.js new file mode 100644 index 0000000..27f55ca --- /dev/null +++ b/scripts/db/migrate-chats.js @@ -0,0 +1,52 @@ +import { Pool } from 'pg'; + +const SQL = ` +create extension if not exists pgcrypto; + +create table if not exists chats ( + id uuid primary key default gen_random_uuid(), + user_id text not null, + title text not null default 'New chat', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists chats_user_id_created_at_idx + on chats (user_id, created_at desc); + +create table if not exists chat_messages ( + id uuid primary key default gen_random_uuid(), + chat_id uuid not null references chats(id) on delete cascade, + role text not null, + content text not null, + created_at timestamptz not null default now() +); + +create index if not exists chat_messages_chat_id_created_at_idx + on chat_messages (chat_id, created_at asc); +`; + +async function main() { + if (!process.env.DATABASE_URL) { + console.error('DATABASE_URL is not set'); + process.exit(1); + } + + const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: + process.env.DATABASE_URL?.includes('supabase.co') || + process.env.NODE_ENV === 'production' + ? { rejectUnauthorized: false } + : false, + }); + + await pool.query(SQL); + console.log('✅ chat tables migrated'); + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 346e8f6bd8ce20f478e941b816672c7f0f8c4d89 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 15:54:54 +0800 Subject: [PATCH 2/9] chore: remove next-env.d.ts and update .gitignore --- .gitignore | 1 + next-env.d.ts | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 next-env.d.ts diff --git a/.gitignore b/.gitignore index ff09d8d..2736e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ pnpm-debug.log* *.db .vercel .env*.local +next-env.d.ts diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index 1511519..0000000 --- a/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import './.next/types/routes.d.ts'; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 8f2d72389e2227671fdb53889a43b2fb876280cf Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 16:01:00 +0800 Subject: [PATCH 3/9] fix: use ref for currentChatId in message transport and fetching logic --- app/page.tsx | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index e0cf44b..b08b284 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { Conversation, @@ -54,16 +54,17 @@ export default function ConversationDemo() { const [chats, setChats] = useState([]); const [currentChatId, setCurrentChatId] = useState(null); - const [loaded, setLoaded] = useState>(new Set()); + const currentChatIdRef = useRef(null); + currentChatIdRef.current = currentChatId; const transport = useMemo(() => { return new DefaultChatTransport({ api: '/api/chat', prepareSendMessagesRequest: ({ messages }) => ({ - body: { messages, chatId: currentChatId }, + body: { messages, chatId: currentChatIdRef.current }, }), }); - }, [currentChatId]); + }, []); const { messages, sendMessage, status, setMessages } = useChat({ transport }); @@ -102,16 +103,15 @@ export default function ConversationDemo() { useEffect(() => { if (!session || !currentChatId) return; - if (loaded.has(currentChatId)) return; - (async () => { const res = await fetch(`/api/chats/${currentChatId}`); if (!res.ok) return; const data = (await res.json()) as { messages: DbMessage[] }; - setMessages(dbMessagesToUiMessages(data.messages ?? [])); - setLoaded((prev) => new Set([...prev, currentChatId])); + if (currentChatIdRef.current === currentChatId) { + setMessages(dbMessagesToUiMessages(data.messages ?? [])); + } })(); - }, [session, currentChatId, loaded, setMessages]); + }, [session, currentChatId, setMessages]); const handleNewChat = async () => { const res = await fetch('/api/chats', { @@ -131,27 +131,19 @@ export default function ConversationDemo() { setChats((prev) => [chat, ...prev]); setCurrentChatId(chat.id); - setLoaded((prev) => { - const next = new Set(prev); - next.add(chat.id); - return next; - }); setMessages([]); }; const handleChatSelect = async (chatId: string) => { + if (chatId === currentChatId) return; setCurrentChatId(chatId); + setMessages([]); }; const handleDeleteChat = async (chatId: string) => { await fetch(`/api/chats/${chatId}`, { method: 'DELETE' }); setChats((prev) => prev.filter((c) => c.id !== chatId)); - setLoaded((prev) => { - const next = new Set(prev); - next.delete(chatId); - return next; - }); if (currentChatId === chatId) { setCurrentChatId(null); @@ -178,13 +170,9 @@ export default function ConversationDemo() { createdAt: new Date(data.chat.createdAt), }; setChats((prev) => [chat, ...prev]); - setCurrentChatId(chat.id); - setLoaded((prev) => { - const next = new Set(prev); - next.add(chat.id); - return next; - }); chatId = chat.id; + currentChatIdRef.current = chatId; + setCurrentChatId(chatId); setMessages([]); } From ed5f4d89efa350ac2b3c73065c2b9475ba6c83d1 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 16:01:31 +0800 Subject: [PATCH 4/9] feat: auto-generate chat title on first message --- app/api/chat/route.ts | 27 ++++++++++++++++++++++++++- app/page.tsx | 41 +++++++++++++++++++++++++---------------- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 64a431d..bf70a67 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,4 +1,9 @@ -import { streamText, UIMessage, convertToModelMessages } from 'ai'; +import { + streamText, + generateText, + UIMessage, + convertToModelMessages, +} from 'ai'; import { auth } from '@/lib/auth'; import { getDbPool } from '@/lib/db'; @@ -35,6 +40,26 @@ export async function POST(req: Request) { await pool.query(`update chats set updated_at = now() where id = $1`, [ chatId, ]); + + // Auto-generate title if this is the first message + if (messages.length === 1) { + (async () => { + try { + const { text: title } = await generateText({ + model: 'anthropic/claude-haiku-4.5', + system: + 'You are a title generator. Create a concise, 3-5 word title for a chat based on the user message. Do not use quotes, bolding, or punctuation.', + prompt: uiMessageToText(last), + }); + await pool.query('update chats set title = $1 where id = $2', [ + title.trim().slice(0, 100) || 'New Chat', + chatId, + ]); + } catch (error) { + console.error('Title generation failed:', error); + } + })(); + } } } diff --git a/app/page.tsx b/app/page.tsx index b08b284..acfb52f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState, useRef } from 'react'; +import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Conversation, @@ -73,32 +73,41 @@ export default function ConversationDemo() { [chats, currentChatId] ); + const fetchChats = useCallback(async () => { + if (isPending || !session) return; + const res = await fetch('/api/chats'); + if (!res.ok) return; + const data = (await res.json()) as { chats: DbChat[] }; + const nextChats: Chat[] = (data.chats ?? []).map((c) => ({ + id: c.id, + title: c.title, + createdAt: new Date(c.createdAt), + })); + setChats(nextChats); + return nextChats; + }, [isPending, session]); + useEffect(() => { if (!isPending && !session) router.push('/login'); }, [session, isPending, router]); useEffect(() => { - if (isPending || !session) return; - - (async () => { - const res = await fetch('/api/chats'); - if (!res.ok) return; - const data = (await res.json()) as { chats: DbChat[] }; - const nextChats: Chat[] = (data.chats ?? []).map((c) => ({ - id: c.id, - title: c.title, - createdAt: new Date(c.createdAt), - })); - setChats(nextChats); - + fetchChats().then((nextChats) => { // Auto-select the most recent chat (if any) - if (nextChats.length > 0 && !currentChatId) { + if (nextChats && nextChats.length > 0 && !currentChatId) { setCurrentChatId(nextChats[0].id); } - })(); + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [session, isPending]); + // Refresh chats list when status changes to ready (to pick up auto-generated titles) + useEffect(() => { + if (status === 'ready' && currentChatId) { + fetchChats(); + } + }, [status, currentChatId, fetchChats]); + // Load messages whenever current chat changes useEffect(() => { if (!session || !currentChatId) return; From 54edbb2b288721f1a1dc5bc3ebe00bd04c046295 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 16:04:29 +0800 Subject: [PATCH 5/9] feat: persist chat history, auto-generate titles, and refactor page component --- app/page.tsx | 162 +++++-------------------------- components/chat-message-list.tsx | 56 +++++++++++ hooks/use-chats.ts | 72 ++++++++++++++ lib/chat-types.ts | 23 +++++ 4 files changed, 175 insertions(+), 138 deletions(-) create mode 100644 components/chat-message-list.tsx create mode 100644 hooks/use-chats.ts create mode 100644 lib/chat-types.ts diff --git a/app/page.tsx b/app/page.tsx index acfb52f..5fd3090 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,59 +1,38 @@ 'use client'; -import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { Conversation, - ConversationContent, - ConversationEmptyState, ConversationScrollButton, } from '@/components/ai-elements/conversation'; -import { - Message, - MessageContent, - MessageResponse, -} from '@/components/ai-elements/message'; import { PromptInput, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, } from '@/components/ai-elements/prompt-input'; -import { ChatSidebar, type Chat } from '@/components/chat-sidebar'; +import { ChatSidebar } from '@/components/chat-sidebar'; import { useSession } from '@/lib/auth-client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; -import { MessageSquare } from 'lucide-react'; -import type { UIMessage } from 'ai'; - -type DbChat = { - id: string; - title: string; - createdAt: string; - updatedAt: string; -}; - -type DbMessage = { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - createdAt: string; -}; - -function dbMessagesToUiMessages(rows: DbMessage[]): UIMessage[] { - return rows.map((m) => ({ - id: m.id, - role: m.role, - parts: [{ type: 'text', text: m.content }], - })); -} +import { useChats } from '@/hooks/use-chats'; +import { ChatMessageList } from '@/components/chat-message-list'; +import { dbMessagesToUiMessages, type DbMessage } from '@/lib/chat-types'; export default function ConversationDemo() { const router = useRouter(); const { data: session, isPending } = useSession(); - const [chats, setChats] = useState([]); - const [currentChatId, setCurrentChatId] = useState(null); + const { + chats, + currentChatId, + setCurrentChatId, + fetchChats, + handleNewChat, + handleDeleteChat, + } = useChats(session, isPending); + const currentChatIdRef = useRef(null); currentChatIdRef.current = currentChatId; @@ -73,34 +52,10 @@ export default function ConversationDemo() { [chats, currentChatId] ); - const fetchChats = useCallback(async () => { - if (isPending || !session) return; - const res = await fetch('/api/chats'); - if (!res.ok) return; - const data = (await res.json()) as { chats: DbChat[] }; - const nextChats: Chat[] = (data.chats ?? []).map((c) => ({ - id: c.id, - title: c.title, - createdAt: new Date(c.createdAt), - })); - setChats(nextChats); - return nextChats; - }, [isPending, session]); - useEffect(() => { if (!isPending && !session) router.push('/login'); }, [session, isPending, router]); - useEffect(() => { - fetchChats().then((nextChats) => { - // Auto-select the most recent chat (if any) - if (nextChats && nextChats.length > 0 && !currentChatId) { - setCurrentChatId(nextChats[0].id); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session, isPending]); - // Refresh chats list when status changes to ready (to pick up auto-generated titles) useEffect(() => { if (status === 'ready' && currentChatId) { @@ -122,42 +77,15 @@ export default function ConversationDemo() { })(); }, [session, currentChatId, setMessages]); - const handleNewChat = async () => { - const res = await fetch('/api/chats', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ title: `Chat ${chats.length + 1}` }), - }); - - if (!res.ok) return; - - const data = (await res.json()) as { chat: DbChat }; - const chat: Chat = { - id: data.chat.id, - title: data.chat.title, - createdAt: new Date(data.chat.createdAt), - }; - - setChats((prev) => [chat, ...prev]); - setCurrentChatId(chat.id); - setMessages([]); - }; - const handleChatSelect = async (chatId: string) => { if (chatId === currentChatId) return; setCurrentChatId(chatId); setMessages([]); }; - const handleDeleteChat = async (chatId: string) => { - await fetch(`/api/chats/${chatId}`, { method: 'DELETE' }); - - setChats((prev) => prev.filter((c) => c.id !== chatId)); - - if (currentChatId === chatId) { - setCurrentChatId(null); - setMessages([]); - } + const onNewChatButtonClick = async () => { + await handleNewChat(); + setMessages([]); }; const handleSendMessage = async ({ text }: { text: string }) => { @@ -165,27 +93,13 @@ export default function ConversationDemo() { let chatId = currentChatId; if (!chatId) { - // Create on-demand - const res = await fetch('/api/chats', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ title: `Chat ${chats.length + 1}` }), - }); - if (!res.ok) return; - const data = (await res.json()) as { chat: DbChat }; - const chat: Chat = { - id: data.chat.id, - title: data.chat.title, - createdAt: new Date(data.chat.createdAt), - }; - setChats((prev) => [chat, ...prev]); + const chat = await handleNewChat(); + if (!chat) return; chatId = chat.id; currentChatIdRef.current = chatId; - setCurrentChatId(chatId); setMessages([]); } - // transport will include { chatId } in the request body sendMessage({ text }); }; @@ -196,43 +110,15 @@ export default function ConversationDemo() { chats={chats} currentChatId={currentChatId} onChatSelect={handleChatSelect} - onNewChat={handleNewChat} + onNewChat={onNewChatButtonClick} onDeleteChat={handleDeleteChat} >
- - {messages.length === 0 ? ( - } - title={ - currentChatTitle - ? `Start: ${currentChatTitle}` - : 'Start a conversation' - } - description="Type a message below to begin chatting" - /> - ) : ( - messages.map((message) => ( - - - {message.parts.map((part: any, i: number) => { - switch (part.type) { - case 'text': - return ( - - {part.text} - - ); - default: - return null; - } - })} - - - )) - )} - + diff --git a/components/chat-message-list.tsx b/components/chat-message-list.tsx new file mode 100644 index 0000000..cb546ee --- /dev/null +++ b/components/chat-message-list.tsx @@ -0,0 +1,56 @@ +import { MessageSquare } from 'lucide-react'; +import { + ConversationEmptyState, + ConversationContent, +} from '@/components/ai-elements/conversation'; +import { + Message, + MessageContent, + MessageResponse, +} from '@/components/ai-elements/message'; +import type { UIMessage } from 'ai'; + +interface ChatMessageListProps { + messages: UIMessage[]; + currentChatTitle?: string; +} + +export function ChatMessageList({ + messages, + currentChatTitle, +}: ChatMessageListProps) { + return ( + + {messages.length === 0 ? ( + } + title={ + currentChatTitle + ? `Start: ${currentChatTitle}` + : 'Start a conversation' + } + description="Type a message below to begin chatting" + /> + ) : ( + messages.map((message) => ( + + + {message.parts.map((part: any, i: number) => { + switch (part.type) { + case 'text': + return ( + + {part.text} + + ); + default: + return null; + } + })} + + + )) + )} + + ); +} diff --git a/hooks/use-chats.ts b/hooks/use-chats.ts new file mode 100644 index 0000000..f3f66b6 --- /dev/null +++ b/hooks/use-chats.ts @@ -0,0 +1,72 @@ +import { useState, useCallback, useEffect } from 'react'; +import type { Chat } from '@/components/chat-sidebar'; +import type { DbChat } from '@/lib/chat-types'; + +export function useChats(session: any, isPending: boolean) { + const [chats, setChats] = useState([]); + const [currentChatId, setCurrentChatId] = useState(null); + + const fetchChats = useCallback(async () => { + if (isPending || !session) return; + const res = await fetch('/api/chats'); + if (!res.ok) return; + const data = (await res.json()) as { chats: DbChat[] }; + const nextChats: Chat[] = (data.chats ?? []).map((c) => ({ + id: c.id, + title: c.title, + createdAt: new Date(c.createdAt), + })); + setChats(nextChats); + return nextChats; + }, [isPending, session]); + + useEffect(() => { + fetchChats().then((nextChats) => { + if (nextChats && nextChats.length > 0 && !currentChatId) { + setCurrentChatId(nextChats[0].id); + } + }); + }, [session, isPending, fetchChats, currentChatId]); + + const handleNewChat = useCallback(async () => { + const res = await fetch('/api/chats', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: `Chat ${chats.length + 1}` }), + }); + + if (!res.ok) return null; + + const data = (await res.json()) as { chat: DbChat }; + const chat: Chat = { + id: data.chat.id, + title: data.chat.title, + createdAt: new Date(data.chat.createdAt), + }; + + setChats((prev) => [chat, ...prev]); + setCurrentChatId(chat.id); + return chat; + }, [chats.length]); + + const handleDeleteChat = useCallback( + async (chatId: string) => { + await fetch(`/api/chats/${chatId}`, { method: 'DELETE' }); + setChats((prev) => prev.filter((c) => c.id !== chatId)); + if (currentChatId === chatId) { + setCurrentChatId(null); + } + }, + [currentChatId] + ); + + return { + chats, + setChats, + currentChatId, + setCurrentChatId, + fetchChats, + handleNewChat, + handleDeleteChat, + }; +} diff --git a/lib/chat-types.ts b/lib/chat-types.ts new file mode 100644 index 0000000..f68d9c0 --- /dev/null +++ b/lib/chat-types.ts @@ -0,0 +1,23 @@ +import type { UIMessage } from 'ai'; + +export type DbChat = { + id: string; + title: string; + createdAt: string; + updatedAt: string; +}; + +export type DbMessage = { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + createdAt: string; +}; + +export function dbMessagesToUiMessages(rows: DbMessage[]): UIMessage[] { + return rows.map((m) => ({ + id: m.id, + role: m.role, + parts: [{ type: 'text', text: m.content }], + })); +} From 0234bc3a09eff3dfaed1675dbe50c4bc13ceddd5 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 16:23:01 +0800 Subject: [PATCH 6/9] feat: refine chatui --- app/page.tsx | 6 +++--- components/ui/input-group.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 5fd3090..113bae1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -119,11 +119,11 @@ export default function ConversationDemo() { messages={messages} currentChatTitle={currentChatTitle} /> - + -
-
+
+
diff --git a/components/ui/input-group.tsx b/components/ui/input-group.tsx index 1ce3199..a2f3a12 100644 --- a/components/ui/input-group.tsx +++ b/components/ui/input-group.tsx @@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="input-group" role="group" className={cn( - "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", + "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-2xl shadow-md transition-[color,box-shadow] outline-none", "h-9 min-w-0 has-[>textarea]:h-auto", // Variants based on alignment. From f5cab7062f37eb15267f085c85b23a2d14ab69ce Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 16:24:17 +0800 Subject: [PATCH 7/9] feat: increase padding in chat message list for improved layout --- components/chat-message-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/chat-message-list.tsx b/components/chat-message-list.tsx index cb546ee..c03d555 100644 --- a/components/chat-message-list.tsx +++ b/components/chat-message-list.tsx @@ -20,7 +20,7 @@ export function ChatMessageList({ currentChatTitle, }: ChatMessageListProps) { return ( - + {messages.length === 0 ? ( } From 5eecdeeed56484a21491152ce5b2743150245c11 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 17:02:36 +0800 Subject: [PATCH 8/9] feat: enhance chat message list with regeneration and loading state functionality --- app/global.css | 1 + app/page.tsx | 6 +- components/chat-message-list.tsx | 123 ++++++++++++++++++++++++++----- 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/app/global.css b/app/global.css index 0a445cd..1bb3ce4 100644 --- a/app/global.css +++ b/app/global.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@source '../node_modules/streamdown/dist/*.js'; @custom-variant dark (&:is(.dark *)); diff --git a/app/page.tsx b/app/page.tsx index 113bae1..0dddb80 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -45,7 +45,9 @@ export default function ConversationDemo() { }); }, []); - const { messages, sendMessage, status, setMessages } = useChat({ transport }); + const { messages, sendMessage, status, setMessages, regenerate } = useChat({ + transport, + }); const currentChatTitle = useMemo( () => chats.find((c) => c.id === currentChatId)?.title, @@ -118,6 +120,8 @@ export default function ConversationDemo() { diff --git a/components/chat-message-list.tsx b/components/chat-message-list.tsx index c03d555..2f75591 100644 --- a/components/chat-message-list.tsx +++ b/components/chat-message-list.tsx @@ -1,4 +1,4 @@ -import { MessageSquare } from 'lucide-react'; +import { MessageSquare, RefreshCcwIcon, CopyIcon } from 'lucide-react'; import { ConversationEmptyState, ConversationContent, @@ -7,17 +7,31 @@ import { Message, MessageContent, MessageResponse, + MessageActions, + MessageAction, + MessageBranch, + MessageBranchContent, + MessageBranchSelector, + MessageBranchPrevious, + MessageBranchNext, + MessageBranchPage, + MessageToolbar, } from '@/components/ai-elements/message'; import type { UIMessage } from 'ai'; +import { cn } from '@/lib/utils'; interface ChatMessageListProps { messages: UIMessage[]; currentChatTitle?: string; + onRegenerate?: (options?: { messageId?: string }) => void; + isLoading?: boolean; } export function ChatMessageList({ messages, currentChatTitle, + onRegenerate, + isLoading, }: ChatMessageListProps) { return ( @@ -32,24 +46,95 @@ export function ChatMessageList({ description="Type a message below to begin chatting" /> ) : ( - messages.map((message) => ( - - - {message.parts.map((part: any, i: number) => { - switch (part.type) { - case 'text': - return ( - - {part.text} - - ); - default: - return null; - } - })} - - - )) + messages.map((message, index) => { + const isLatest = index === messages.length - 1; + + return ( + + + + + {message.parts.map((part: any, i: number) => { + if (part.type === 'text') { + return ( + + {part.text} + + ); + } + return null; + })} + + + + + {message.role === 'assistant' && ( + + {onRegenerate && ( + + onRegenerate({ messageId: message.id }) + } + label="Retry" + tooltip="Regenerate response" + > + + + )} + { + const text = message.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text) + .join(''); + navigator.clipboard.writeText(text); + }} + label="Copy" + tooltip="Copy message" + > + + + + )} + + + + + + + +
+ + {message.role === 'user' && ( + + { + const text = message.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text) + .join(''); + navigator.clipboard.writeText(text); + }} + label="Copy" + tooltip="Copy message" + > + + + + )} + + + + ); + }) )} ); From 05de639a7b6689d08091e923b02c1474c7c92909 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Mon, 2 Feb 2026 17:03:51 +0800 Subject: [PATCH 9/9] feat: remove message icon from chat sidebar for cleaner layout --- components/chat-sidebar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/chat-sidebar.tsx b/components/chat-sidebar.tsx index 4c238e4..93eadf1 100644 --- a/components/chat-sidebar.tsx +++ b/components/chat-sidebar.tsx @@ -93,7 +93,6 @@ function ChatSidebarContent({ onClick={() => onChatSelect(chat.id)} tooltip={chat.title} > - {chat.title}