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, })); }, });