Skip to content
This repository was archived by the owner on Dec 2, 2025. It is now read-only.
Open
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
30 changes: 15 additions & 15 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,26 @@ services:
container_name: redis-open3
restart: unless-stopped
# Uncomment this for development to expose Redis port
# ports:
# - "6379:6379"
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]

# Comment this out if you want to run a development environment locally
web:
build: .
container_name: open3-web
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- REDIS_URL=redis-open3:6379
depends_on:
- redis
volumes:
- upload-data:/app/public/uploads
# web:
# build: .
# container_name: open3-web
# restart: unless-stopped
# ports:
# - "3000:3000"
# environment:
# - NODE_ENV=production
# - REDIS_URL=redis-open3:6379
# depends_on:
# - redis
# volumes:
# - upload-data:/app/public/uploads

volumes:
redis-data:
Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"parse-numeric-range": "^1.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
Expand All @@ -57,4 +58,4 @@
"tailwindcss": "^4",
"typescript": "^5"
}
}
}
59 changes: 52 additions & 7 deletions src/app/api/chat/[id]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, currentUser } from "@clerk/nextjs/server";
import redis, { CHAT_GENERATING_KEY, CHAT_MESSAGES_KEY, USER_CHATS_KEY } from "@/internal-lib/redis";
import redis, { CHAT_MESSAGES_KEY, USER_CHATS_KEY } from "@/internal-lib/redis";
import { Message } from "@/app/lib/types/ai";
import { ApiError } from "@/internal-lib/types/api";

export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export interface ChatMessagesResponse {
messages: Message[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}

export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!redis) {
return NextResponse.json({ error: "Redis connection failure" } as ApiError, { status: 500 });
}
Expand All @@ -13,17 +21,50 @@ export async function GET(_: NextRequest, { params }: { params: Promise<{ id: st
if (!user) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 });
if (!user.userId) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 });

// Pagination parameters
const page = parseInt(req.nextUrl.searchParams.get("page") || "1");
const limit = parseInt(req.nextUrl.searchParams.get("limit") || "25");
const reverse = req.nextUrl.searchParams.get("reverse") === "true";
if (page < 1) {
return NextResponse.json({ error: "Page must be greater than 0" } as ApiError, { status: 400 });
}
if (limit < 1 || limit > 100) {
return NextResponse.json({ error: "Limit must be between 1 and 100" } as ApiError, { status: 400 });
}
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit - 1;

const { id } = await params;
const chatExists = await redis.hexists(USER_CHATS_KEY(user.userId), id);
if (!chatExists) {
return NextResponse.json({ error: "Chat not found" } as ApiError, { status: 404 });
}

const isGenerating = await redis.get(CHAT_GENERATING_KEY(id));

try {
const total = await redis.llen(CHAT_MESSAGES_KEY(id));
let messageStrings: string[] = [];
messageStrings = await redis.lrange(CHAT_MESSAGES_KEY(id), 0, -1);

if (reverse) {
// Reverse pagination: newest messages first
// Calculate indices from the end
const reverseStart = total - (page * limit);
const reverseEnd = total - ((page - 1) * limit) - 1;
// Clamp indices to valid range
const start = Math.max(reverseStart, 0);
const end = Math.max(reverseEnd, 0);

// Redis lrange is inclusive, so ensure start <= end
if (start <= end) {
messageStrings = await redis.lrange(CHAT_MESSAGES_KEY(id), start, end);
} else {
messageStrings = [];
}
// Since lrange returns oldest-to-newest, reverse to get newest-to-oldest
messageStrings = messageStrings.reverse();
} else {
// Normal pagination: oldest messages first
messageStrings = await redis.lrange(CHAT_MESSAGES_KEY(id), startIndex, endIndex);
}

const messages: Message[] = messageStrings.map(msgStr => {
try {
Expand All @@ -33,10 +74,14 @@ export async function GET(_: NextRequest, { params }: { params: Promise<{ id: st
}
}).filter(Boolean);

console.log(`Retrieved ${messages.length} messages for chat ${id} on page ${page} with limit ${limit}. Total messages: ${total} hasMore: ${total > endIndex + 1}`);
return NextResponse.json({
messages,
generating: !!isGenerating,
}, { status: 200 });
total,
page,
limit,
hasMore: total > endIndex + 1,
} as ChatMessagesResponse, { status: 200 });
} catch (error) {
console.error("Failed to retrieve messages:", error);
return NextResponse.json({ error: "Failed to retrieve messages" } as ApiError, { status: 500 });
Expand Down
67 changes: 60 additions & 7 deletions src/app/api/chat/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Message } from "@/app/lib/types/ai";
import { NextRequest, NextResponse } from "next/server";
import { auth, currentUser } from "@clerk/nextjs/server";
import redis, { USER_CHATS_KEY, USER_CHATS_INDEX_KEY, CHAT_MESSAGES_KEY, USER_FILES_KEY, MESSAGE_STREAM_KEY } from "@/internal-lib/redis";
import redis, { USER_CHATS_KEY, USER_CHATS_INDEX_KEY, CHAT_MESSAGES_KEY, USER_FILES_KEY, MESSAGE_STREAM_KEY, USER_PINNED_CHATS_KEY } from "@/internal-lib/redis";
import { join } from "path";
import { unlink } from "fs/promises";
import { ApiError } from "@/internal-lib/types/api";
Expand All @@ -10,6 +10,7 @@ import { ApiError } from "@/internal-lib/types/api";
interface ChatResponse {
id: string;
label: string;
pinned?: boolean;
model: string;
provider: string;
history: Message[];
Expand Down Expand Up @@ -45,6 +46,58 @@ export async function GET(_: NextRequest, { params }: { params: Promise<{ id: st
} as ChatResponse, { status: 200 });
}

export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!redis) {
return NextResponse.json({ error: "Redis connection failure" } as ApiError, { status: 200 });
}

try {
const updateBody = await req.json() as {
label?: string;
pinned?: boolean;
};

// Check if no fields are provided in the body
if (updateBody.label === undefined && updateBody.pinned === undefined) { // It's just label atm, model/provider can't be changed via updating the chat only by sending
return NextResponse.json({ error: "At least one field is required (label, pinned)" } as ApiError, { status: 400 });
}

const user = await currentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 200 });
if (user.banned) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 200 });

const { id } = await params;

const rawChat = await redis.hget(USER_CHATS_KEY(user.id), id);
if (!rawChat) return NextResponse.json({ error: "Chat not found" } as ApiError, { status: 200 });
const chat = JSON.parse(rawChat);

// If label is provided, update label
if (updateBody.label !== undefined && updateBody.label.trim() !== "") {
if (updateBody.label.trim().length > 100) {
return NextResponse.json({ error: "Label is too long, maximum length is 100 characters" } as ApiError, { status: 400 });
}
chat.label = updateBody.label.trim();
}
// If pinned is provided, update pinned
if (updateBody.pinned !== undefined) {
if (updateBody.pinned) {
await redis.zadd(USER_PINNED_CHATS_KEY(user.id), Date.now(), id);
} else {
await redis.zrem(USER_PINNED_CHATS_KEY(user.id), id);
}
chat.pinned = updateBody.pinned;
}

// Update the chat
await redis.hset(USER_CHATS_KEY(user.id), id, JSON.stringify(chat));
return NextResponse.json({ success: "Updated chat successfully" }, { status: 200 });
} catch (error) {
console.error("Unknown error occurred while updating a chat: ", (error as Error).message);
return NextResponse.json({ error: "An unknown error occurred" }, { status: 500 });
}
}

export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!redis) {
return NextResponse.json({ error: "Redis connection failure" } as ApiError, { status: 500 });
Expand All @@ -54,9 +107,9 @@ export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id:
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 });
if (user.banned) return NextResponse.json({ error: "Unauthorized" } as ApiError, { status: 401 });

const { id } = await params;

// Delete all files belonging to this chat
const USER_FILES_KEY_CONST = USER_FILES_KEY(user.id);
const files = await redis.hgetall(USER_FILES_KEY_CONST);
Expand All @@ -76,24 +129,24 @@ export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id:
console.error(`Failed to delete file ${randomName} for chat ${id}:`, error);
}
}

// Use a transaction to delete both chat data and messages
const result = await redis.multi()
.hdel(USER_CHATS_KEY(user.id), id)
.del(CHAT_MESSAGES_KEY(id))
.zrem(USER_CHATS_INDEX_KEY(user.id), id)
.exec();

// Clean the redis stream to prevent duplicates
await redis.del(MESSAGE_STREAM_KEY(id)).catch((err) => {
console.error("Failed to trim message stream:", err);
});

// Check if chat deletion was successful (first operation)
if (!result || result[0][1] === 0) {
return NextResponse.json({ error: "Failed to delete chat" } as ApiError, { status: 404 });
}

return NextResponse.json({ success: "Chat deleted" }, { status: 200 });
} catch (error) {
console.error("Error deleting chat:", error);
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/chat/[id]/send/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:

if (requestedModel !== chatJson.model || requestedProvider !== chatJson.provider) {
// If the requested model or provider does not match the chat's model/provider, update the chat
const chatCopy = Object.assign({}, chatJson as any);
const chatCopy = { ...chatJson } as any;
chatCopy.model = chatModel;
chatCopy.provider = chatProvider;
delete chatCopy.id;
Expand Down
Loading