Files
wiki/convex/aiChats.ts

414 lines
9.0 KiB
TypeScript

import { v } from "convex/values";
import {
query,
mutation,
internalQuery,
internalMutation,
} from "./_generated/server";
// Message validator for reuse
const messageValidator = v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
timestamp: v.number(),
attachments: v.optional(
v.array(
v.object({
type: v.union(v.literal("image"), v.literal("link")),
storageId: v.optional(v.id("_storage")),
url: v.optional(v.string()),
scrapedContent: v.optional(v.string()),
title: v.optional(v.string()),
}),
),
),
});
/**
* Get storage URL for an image attachment
*/
export const getStorageUrl = query({
args: {
storageId: v.id("_storage"),
},
returns: v.union(v.string(), v.null()),
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
/**
* Get AI chat by session and context
* Returns null if no chat exists
*/
export const getAIChatByContext = query({
args: {
sessionId: v.string(),
contextId: v.string(),
},
returns: v.union(
v.object({
_id: v.id("aiChats"),
_creationTime: v.number(),
sessionId: v.string(),
contextId: v.string(),
messages: v.array(messageValidator),
pageContext: v.optional(v.string()),
lastMessageAt: v.optional(v.number()),
}),
v.null(),
),
handler: async (ctx, args) => {
const chat = await ctx.db
.query("aiChats")
.withIndex("by_session_and_context", (q) =>
q.eq("sessionId", args.sessionId).eq("contextId", args.contextId),
)
.first();
return chat;
},
});
/**
* Internal query for use in actions
*/
export const getAIChatInternal = internalQuery({
args: {
chatId: v.id("aiChats"),
},
returns: v.union(
v.object({
_id: v.id("aiChats"),
_creationTime: v.number(),
sessionId: v.string(),
contextId: v.string(),
messages: v.array(messageValidator),
pageContext: v.optional(v.string()),
lastMessageAt: v.optional(v.number()),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get(args.chatId);
},
});
/**
* Get storage URL for an image attachment (internal)
*/
export const getStorageUrlInternal = internalQuery({
args: {
storageId: v.id("_storage"),
},
returns: v.union(v.string(), v.null()),
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
/**
* Get or create AI chat for session and context
* Returns the chat ID (creates new chat if needed)
*/
export const getOrCreateAIChat = mutation({
args: {
sessionId: v.string(),
contextId: v.string(),
},
returns: v.id("aiChats"),
handler: async (ctx, args) => {
// Check for existing chat
const existing = await ctx.db
.query("aiChats")
.withIndex("by_session_and_context", (q) =>
q.eq("sessionId", args.sessionId).eq("contextId", args.contextId),
)
.first();
if (existing) {
return existing._id;
}
// Create new chat
const chatId = await ctx.db.insert("aiChats", {
sessionId: args.sessionId,
contextId: args.contextId,
messages: [],
lastMessageAt: Date.now(),
});
return chatId;
},
});
/**
* Add user message to chat
* Returns the updated chat
*/
export const addUserMessage = mutation({
args: {
chatId: v.id("aiChats"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
const now = Date.now();
const newMessage = {
role: "user" as const,
content: args.content,
timestamp: now,
};
await ctx.db.patch(args.chatId, {
messages: [...chat.messages, newMessage],
lastMessageAt: now,
});
return null;
},
});
/**
* Add user message with attachments
* Used when sending images or links
*/
export const addUserMessageWithAttachments = mutation({
args: {
chatId: v.id("aiChats"),
content: v.string(),
attachments: v.optional(
v.array(
v.object({
type: v.union(v.literal("image"), v.literal("link")),
storageId: v.optional(v.id("_storage")),
url: v.optional(v.string()),
scrapedContent: v.optional(v.string()),
title: v.optional(v.string()),
}),
),
),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
const now = Date.now();
const newMessage = {
role: "user" as const,
content: args.content,
timestamp: now,
attachments: args.attachments,
};
await ctx.db.patch(args.chatId, {
messages: [...chat.messages, newMessage],
lastMessageAt: now,
});
return null;
},
});
/**
* Generate upload URL for image attachments
*/
export const generateUploadUrl = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
/**
* Add assistant message to chat (internal - called from action)
*/
export const addAssistantMessage = internalMutation({
args: {
chatId: v.id("aiChats"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
const now = Date.now();
const newMessage = {
role: "assistant" as const,
content: args.content,
timestamp: now,
};
await ctx.db.patch(args.chatId, {
messages: [...chat.messages, newMessage],
lastMessageAt: now,
});
return null;
},
});
/**
* Clear all messages from a chat
*/
export const clearChat = mutation({
args: {
chatId: v.id("aiChats"),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
return null; // Idempotent - no error if chat doesn't exist
}
await ctx.db.patch(args.chatId, {
messages: [],
pageContext: undefined,
lastMessageAt: Date.now(),
});
return null;
},
});
/**
* Set page context for a chat (loads page markdown for AI context)
*/
export const setPageContext = mutation({
args: {
chatId: v.id("aiChats"),
pageContext: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
await ctx.db.patch(args.chatId, {
pageContext: args.pageContext,
});
return null;
},
});
/**
* Delete entire chat session
*/
export const deleteChat = mutation({
args: {
chatId: v.id("aiChats"),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
return null; // Idempotent
}
await ctx.db.delete(args.chatId);
return null;
},
});
/**
* Get all chats for a session (for potential future chat history feature)
*/
export const getChatsBySession = query({
args: {
sessionId: v.string(),
},
returns: v.array(
v.object({
_id: v.id("aiChats"),
_creationTime: v.number(),
sessionId: v.string(),
contextId: v.string(),
messages: v.array(messageValidator),
pageContext: v.optional(v.string()),
lastMessageAt: v.optional(v.number()),
}),
),
handler: async (ctx, args) => {
const chats = await ctx.db
.query("aiChats")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.collect();
return chats;
},
});
/**
* Save generated image metadata (internal - called from action)
*/
export const saveGeneratedImage = internalMutation({
args: {
sessionId: v.string(),
prompt: v.string(),
model: v.string(),
storageId: v.id("_storage"),
mimeType: v.string(),
},
returns: v.id("aiGeneratedImages"),
handler: async (ctx, args) => {
const imageId = await ctx.db.insert("aiGeneratedImages", {
sessionId: args.sessionId,
prompt: args.prompt,
model: args.model,
storageId: args.storageId,
mimeType: args.mimeType,
createdAt: Date.now(),
});
return imageId;
},
});
/**
* Get recent generated images for a session (internal - called from action)
*/
export const getRecentImagesInternal = internalQuery({
args: {
sessionId: v.string(),
limit: v.number(),
},
returns: v.array(
v.object({
_id: v.id("aiGeneratedImages"),
_creationTime: v.number(),
sessionId: v.string(),
prompt: v.string(),
model: v.string(),
storageId: v.id("_storage"),
mimeType: v.string(),
createdAt: v.number(),
})
),
handler: async (ctx, args) => {
const images = await ctx.db
.query("aiGeneratedImages")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.order("desc")
.take(args.limit);
return images;
},
});