Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/migrate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
run: pnpm install --frozen-lockfile

- name: Run migrations
run: pnpm run db:migrate
run: pnpm run db:migrate:all
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ pnpm-debug.log*
*.db
.vercel
.env*.local
next-env.d.ts
76 changes: 74 additions & 2 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,83 @@
import { streamText, UIMessage, convertToModelMessages } from 'ai';
import {
streamText,
generateText,
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,
]);

// 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);
}
})();
}
}
}

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();
Expand Down
45 changes: 45 additions & 0 deletions app/api/chats/[chatId]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
63 changes: 63 additions & 0 deletions app/api/chats/[chatId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
46 changes: 46 additions & 0 deletions app/api/chats/route.ts
Original file line number Diff line number Diff line change
@@ -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] });
}
1 change: 1 addition & 0 deletions app/global.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@source '../node_modules/streamdown/dist/*.js';

@custom-variant dark (&:is(.dark *));

Expand Down
Loading