adds ability to star chats

This commit is contained in:
2026-05-28 22:47:45 -07:00
parent cb8ea935fa
commit a6c2ec664b
16 changed files with 779 additions and 145 deletions

View File

@@ -0,0 +1,44 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'folder',
"title" TEXT NOT NULL,
"userId" TEXT,
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ProjectItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"projectId" TEXT NOT NULL,
"chatId" TEXT,
"searchId" TEXT,
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
);
-- CreateIndex
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
-- CreateIndex
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
-- CreateIndex
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
-- CreateIndex
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");

View File

@@ -27,6 +27,11 @@ enum SearchSource {
exa
}
enum ProjectKind {
starred
folder
}
model User {
id String @id @default(cuid())
createdAt DateTime @default(now())
@@ -37,6 +42,7 @@ model User {
chats Chat[]
searches Search[]
projects Project[]
}
model Chat {
@@ -56,6 +62,7 @@ model Chat {
messages Message[]
calls LlmCall[]
projectItems ProjectItem[]
@@index([userId])
}
@@ -129,6 +136,7 @@ model Search {
userId String?
results SearchResult[]
projectItems ProjectItem[]
@@index([updatedAt])
@@index([userId])
@@ -156,3 +164,40 @@ model SearchResult {
@@index([searchId, rank])
}
model Project {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
kind ProjectKind @default(folder)
title String
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
items ProjectItem[]
@@index([kind])
@@index([userId])
}
model ProjectItem {
id String @id @default(cuid())
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
chatId String?
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
searchId String?
@@unique([projectId, chatId])
@@unique([projectId, searchId])
@@index([projectId, createdAt])
@@index([chatId])
@@index([searchId])
}

View File

@@ -321,6 +321,34 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
const activeChatStreams = new Map<string, ActiveSseStream>();
const activeSearchStreams = new Map<string, ActiveSseStream>();
const STARRED_PROJECT_ID = "starred";
const starredProjectItemsSelect = {
where: { projectId: STARRED_PROJECT_ID },
select: { createdAt: true },
take: 1,
} as const;
const chatSummarySelect = {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
projectItems: starredProjectItemsSelect,
} as const;
const searchSummarySelect = {
id: true,
title: true,
query: true,
createdAt: true,
updatedAt: true,
projectItems: starredProjectItemsSelect,
} as const;
function getErrorMessage(err: unknown) {
return err instanceof Error ? err.message : String(err);
@@ -330,32 +358,111 @@ function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: D
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
function serializeStarFields(item: { projectItems?: Array<{ createdAt: Date }> }) {
const star = item.projectItems?.[0];
return {
starred: Boolean(star),
starredAt: star?.createdAt ?? null,
};
}
function serializeChatLike<T extends Record<string, any>>(chat: T) {
const { projectItems: _projectItems, ...rest } = chat;
return {
...serializeProviderFields(rest),
...serializeStarFields(chat),
};
}
function serializeSearchLike<T extends Record<string, any>>(search: T) {
const { projectItems: _projectItems, ...rest } = search;
return {
...rest,
...serializeStarFields(search),
};
}
async function ensureStarredProject() {
await prisma.project.upsert({
where: { id: STARRED_PROJECT_ID },
update: {},
create: {
id: STARRED_PROJECT_ID,
kind: "starred" as any,
title: "Starred",
},
});
}
async function getChatSummary(chatId: string) {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: chatSummarySelect,
});
return chat ? serializeChatLike(chat) : null;
}
async function getSearchSummary(searchId: string) {
const search = await prisma.search.findUnique({
where: { id: searchId },
select: searchSummarySelect,
});
return search ? serializeSearchLike(search) : null;
}
async function setChatStarred(chatId: string, starred: boolean) {
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
if (!exists) return null;
if (starred) {
await ensureStarredProject();
await prisma.projectItem.upsert({
where: { projectId_chatId: { projectId: STARRED_PROJECT_ID, chatId } },
update: {},
create: { projectId: STARRED_PROJECT_ID, chatId },
});
} else {
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, chatId } });
}
return getChatSummary(chatId);
}
async function setSearchStarred(searchId: string, starred: boolean) {
const exists = await prisma.search.findUnique({ where: { id: searchId }, select: { id: true } });
if (!exists) return null;
if (starred) {
await ensureStarredProject();
await prisma.projectItem.upsert({
where: { projectId_searchId: { projectId: STARRED_PROJECT_ID, searchId } },
update: {},
create: { projectId: STARRED_PROJECT_ID, searchId },
});
} else {
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, searchId } });
}
return getSearchSummary(searchId);
}
async function listWorkspaceItems() {
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
select: chatSummarySelect,
}),
prisma.search.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
select: searchSummarySelect,
}),
]);
return [
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
...searches.map((search) => ({ type: "search" as const, ...search })),
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
].sort(compareUpdatedAtDesc);
}
@@ -562,12 +669,15 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
});
if (!search) {
stream.complete({ event: "error", data: { message: "search not found" } });
} else {
stream.complete({ event: "done", data: { search } });
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
}
} catch (err) {
const message = getErrorMessage(err);
@@ -621,18 +731,9 @@ export async function registerRoutes(app: FastifyInstance) {
const chats = await prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
select: chatSummarySelect,
});
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
return { chats: chats.map((chat) => serializeChatLike(chat)) };
});
app.post("/v1/chats", async (req) => {
@@ -681,18 +782,9 @@ export async function registerRoutes(app: FastifyInstance) {
}
: undefined,
},
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
select: chatSummarySelect,
});
return { chat: serializeProviderFields(chat) };
return { chat: serializeChatLike(chat) };
});
app.patch("/v1/chats/:chatId", async (req) => {
@@ -709,21 +801,21 @@ export async function registerRoutes(app: FastifyInstance) {
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
const chat = await getChatSummary(chatId);
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat: serializeProviderFields(chat) };
return { chat };
});
app.patch("/v1/chats/:chatId/star", async (req) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
const Body = z.object({ starred: z.boolean() });
const { chatId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const chat = await setChatStarred(chatId, body.starred);
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
});
app.post("/v1/chats/title/suggest", async (req) => {
@@ -736,19 +828,10 @@ export async function registerRoutes(app: FastifyInstance) {
const existing = await prisma.chat.findUnique({
where: { id: body.chatId },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
select: chatSummarySelect,
});
if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
const suggestedRaw = await generateChatTitle(body.content);
@@ -759,22 +842,10 @@ export async function registerRoutes(app: FastifyInstance) {
data: { title },
});
const chat = await prisma.chat.findUnique({
where: { id: body.chatId },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
const chat = await getChatSummary(body.chatId);
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat: serializeProviderFields(chat) };
return { chat };
});
app.delete("/v1/chats/:chatId", async (req) => {
@@ -799,9 +870,9 @@ export async function registerRoutes(app: FastifyInstance) {
const searches = await prisma.search.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
select: searchSummarySelect,
});
return { searches };
return { searches: searches.map((search) => serializeSearchLike(search)) };
});
app.post("/v1/searches", async (req) => {
@@ -815,8 +886,20 @@ export async function registerRoutes(app: FastifyInstance) {
title: title || null,
query,
},
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
select: searchSummarySelect,
});
return { search: serializeSearchLike(search) };
});
app.patch("/v1/searches/:searchId/star", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const Body = z.object({ starred: z.boolean() });
const { searchId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const search = await setSearchStarred(searchId, body.starred);
if (!search) return app.httpErrors.notFound("search not found");
return { search };
});
@@ -843,10 +926,13 @@ export async function registerRoutes(app: FastifyInstance) {
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
});
if (!search) return app.httpErrors.notFound("search not found");
return { search };
return { search: serializeSearchLike(search) };
});
app.post("/v1/searches/:searchId/chat", async (req) => {
@@ -882,19 +968,10 @@ export async function registerRoutes(app: FastifyInstance) {
},
},
},
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
select: chatSummarySelect,
});
return { chat: serializeProviderFields(chat) };
return { chat: serializeChatLike(chat) };
});
app.post("/v1/searches/:searchId/run", async (req) => {
@@ -979,10 +1056,13 @@ export async function registerRoutes(app: FastifyInstance) {
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
include: {
results: { orderBy: { rank: "asc" } },
projectItems: starredProjectItemsSelect,
},
});
if (!search) return app.httpErrors.notFound("search not found");
return { search };
return { search: serializeSearchLike(search) };
} catch (err: any) {
await prisma.search.update({
where: { id: searchId },
@@ -1037,10 +1117,14 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
include: {
messages: { orderBy: { createdAt: "asc" } },
calls: { orderBy: { createdAt: "desc" } },
projectItems: starredProjectItemsSelect,
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat: serializeProviderFields(chat) };
return { chat: serializeChatLike(chat) };
});
app.post("/v1/chats/:chatId/messages", async (req) => {