267 lines
7.7 KiB
TypeScript
267 lines
7.7 KiB
TypeScript
import { paginationOptsValidator, paginationResultValidator } from "convex/server";
|
||
import { v } from "convex/values";
|
||
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||
import type { Id } from "./_generated/dataModel";
|
||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||
import { assertOwned, requireUserId } from "./lib/helpers";
|
||
|
||
const initialAssistantMessage =
|
||
"Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.";
|
||
|
||
const toolTraceValidator = v.object({
|
||
name: v.string(),
|
||
inputSummary: v.string(),
|
||
resultSummary: v.string(),
|
||
});
|
||
|
||
const chatRoleValidator = v.union(v.literal("user"), v.literal("assistant"));
|
||
|
||
const importMessageValidator = v.object({
|
||
role: chatRoleValidator,
|
||
content: v.string(),
|
||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||
});
|
||
|
||
const sessionValidator = v.object({
|
||
_id: v.id("chatSessions"),
|
||
_creationTime: v.number(),
|
||
userId: v.id("users"),
|
||
title: v.string(),
|
||
createdAt: v.number(),
|
||
updatedAt: v.number(),
|
||
messageCount: v.number(),
|
||
legacyLocalId: v.optional(v.string()),
|
||
isDeleted: v.boolean(),
|
||
});
|
||
|
||
const messageValidator = v.object({
|
||
_id: v.id("chatMessages"),
|
||
_creationTime: v.number(),
|
||
userId: v.id("users"),
|
||
sessionId: v.id("chatSessions"),
|
||
role: chatRoleValidator,
|
||
content: v.string(),
|
||
createdAt: v.number(),
|
||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||
});
|
||
|
||
const promptMessageValidator = v.object({
|
||
role: chatRoleValidator,
|
||
content: v.string(),
|
||
});
|
||
|
||
function normalizeTitle(title: string | undefined) {
|
||
const trimmed = title?.trim();
|
||
return trimmed ? trimmed : "Neuer Chat";
|
||
}
|
||
|
||
function titleFromContent(content: string) {
|
||
const trimmed = content.trim();
|
||
if (!trimmed) return "Neuer Chat";
|
||
return trimmed.length > 44 ? `${trimmed.slice(0, 44)}…` : trimmed;
|
||
}
|
||
|
||
async function requireOwnedSession(
|
||
ctx: QueryCtx | MutationCtx,
|
||
sessionId: Id<"chatSessions">,
|
||
userId: Id<"users">,
|
||
) {
|
||
const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat");
|
||
if (session.isDeleted) throw new Error("Chat nicht gefunden");
|
||
return session;
|
||
}
|
||
|
||
export const listSessions = query({
|
||
args: { paginationOpts: paginationOptsValidator },
|
||
returns: paginationResultValidator(sessionValidator),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
return await ctx.db
|
||
.query("chatSessions")
|
||
.withIndex("by_user_deleted_updated", (index) =>
|
||
index.eq("userId", userId).eq("isDeleted", false),
|
||
)
|
||
.order("desc")
|
||
.paginate(args.paginationOpts);
|
||
},
|
||
});
|
||
|
||
export const listMessages = query({
|
||
args: {
|
||
sessionId: v.id("chatSessions"),
|
||
paginationOpts: paginationOptsValidator,
|
||
},
|
||
returns: paginationResultValidator(messageValidator),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
await requireOwnedSession(ctx, args.sessionId, userId);
|
||
return await ctx.db
|
||
.query("chatMessages")
|
||
.withIndex("by_user_session_created", (index) =>
|
||
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||
)
|
||
.order("desc")
|
||
.paginate(args.paginationOpts);
|
||
},
|
||
});
|
||
|
||
export const createSession = mutation({
|
||
args: { title: v.optional(v.string()) },
|
||
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const now = Date.now();
|
||
const sessionId = await ctx.db.insert("chatSessions", {
|
||
userId,
|
||
title: normalizeTitle(args.title),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
messageCount: 1,
|
||
isDeleted: false,
|
||
});
|
||
await ctx.db.insert("chatMessages", {
|
||
userId,
|
||
sessionId,
|
||
role: "assistant",
|
||
content: initialAssistantMessage,
|
||
createdAt: now,
|
||
});
|
||
return { sessionId };
|
||
},
|
||
});
|
||
|
||
export const deleteSession = mutation({
|
||
args: { sessionId: v.id("chatSessions") },
|
||
returns: v.null(),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
await requireOwnedSession(ctx, args.sessionId, userId);
|
||
await ctx.db.patch(args.sessionId, {
|
||
isDeleted: true,
|
||
updatedAt: Date.now(),
|
||
});
|
||
return null;
|
||
},
|
||
});
|
||
|
||
export const importLocalSession = mutation({
|
||
args: {
|
||
legacyLocalId: v.string(),
|
||
title: v.string(),
|
||
createdAt: v.number(),
|
||
updatedAt: v.number(),
|
||
messages: v.array(importMessageValidator),
|
||
},
|
||
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const existing = await ctx.db
|
||
.query("chatSessions")
|
||
.withIndex("by_user_legacyLocalId", (index) =>
|
||
index.eq("userId", userId).eq("legacyLocalId", args.legacyLocalId),
|
||
)
|
||
.unique();
|
||
if (existing) return { sessionId: existing._id };
|
||
|
||
const sessionId = await ctx.db.insert("chatSessions", {
|
||
userId,
|
||
title: normalizeTitle(args.title),
|
||
createdAt: args.createdAt,
|
||
updatedAt: args.updatedAt,
|
||
messageCount: args.messages.length,
|
||
legacyLocalId: args.legacyLocalId,
|
||
isDeleted: false,
|
||
});
|
||
|
||
for (const [index, message] of args.messages.entries()) {
|
||
await ctx.db.insert("chatMessages", {
|
||
userId,
|
||
sessionId,
|
||
role: message.role,
|
||
content: message.content,
|
||
createdAt: args.createdAt + index,
|
||
...(message.toolTrace ? { toolTrace: message.toolTrace } : {}),
|
||
});
|
||
}
|
||
|
||
return { sessionId };
|
||
},
|
||
});
|
||
|
||
export const appendUserMessage = internalMutation({
|
||
args: {
|
||
sessionId: v.id("chatSessions"),
|
||
content: v.string(),
|
||
},
|
||
returns: v.object({ messageId: v.id("chatMessages") }),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||
const now = Date.now();
|
||
const messageId = await ctx.db.insert("chatMessages", {
|
||
userId,
|
||
sessionId: args.sessionId,
|
||
role: "user",
|
||
content: args.content,
|
||
createdAt: now,
|
||
});
|
||
await ctx.db.patch(args.sessionId, {
|
||
title: session.title === "Neuer Chat" ? titleFromContent(args.content) : session.title,
|
||
updatedAt: now,
|
||
messageCount: session.messageCount + 1,
|
||
});
|
||
return { messageId };
|
||
},
|
||
});
|
||
|
||
export const appendAssistantMessage = internalMutation({
|
||
args: {
|
||
sessionId: v.id("chatSessions"),
|
||
content: v.string(),
|
||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||
},
|
||
returns: v.object({ messageId: v.id("chatMessages") }),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||
const now = Date.now();
|
||
const messageId = await ctx.db.insert("chatMessages", {
|
||
userId,
|
||
sessionId: args.sessionId,
|
||
role: "assistant",
|
||
content: args.content,
|
||
createdAt: now,
|
||
...(args.toolTrace ? { toolTrace: args.toolTrace } : {}),
|
||
});
|
||
await ctx.db.patch(args.sessionId, {
|
||
updatedAt: now,
|
||
messageCount: session.messageCount + 1,
|
||
});
|
||
return { messageId };
|
||
},
|
||
});
|
||
|
||
export const getRecentMessagesForPrompt = internalQuery({
|
||
args: {
|
||
sessionId: v.id("chatSessions"),
|
||
limit: v.number(),
|
||
},
|
||
returns: v.array(promptMessageValidator),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
await requireOwnedSession(ctx, args.sessionId, userId);
|
||
const limit = Math.max(1, Math.min(50, Math.floor(args.limit)));
|
||
const messages = await ctx.db
|
||
.query("chatMessages")
|
||
.withIndex("by_user_session_created", (index) =>
|
||
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||
)
|
||
.order("desc")
|
||
.take(limit);
|
||
return messages.reverse().map((message) => ({
|
||
role: message.role,
|
||
content: message.content,
|
||
}));
|
||
},
|
||
});
|