feat: sync savings chat history with convex
This commit is contained in:
266
convex/savingsChatHistory.ts
Normal file
266
convex/savingsChatHistory.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user