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

@@ -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) => {