feat(*): first mvp

This commit is contained in:
h
2026-01-20 21:54:48 +01:00
parent b9703da2fc
commit ec17f5e0fd
52 changed files with 2599 additions and 576 deletions

View File

@@ -0,0 +1,215 @@
import { v } from 'convex/values';
import { internalMutation, mutation, query } from './_generated/server';
export const listByChat = query({
args: { chatId: v.id('chats') },
returns: v.array(
v.object({
_id: v.id('messages'),
_creationTime: v.number(),
chatId: v.id('chats'),
role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(),
imageStorageId: v.optional(v.id('_storage')),
imageMediaType: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')),
createdAt: v.number(),
isStreaming: v.optional(v.boolean())
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('messages')
.withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId))
.order('asc')
.collect();
}
});
export const create = mutation({
args: {
chatId: v.id('chats'),
role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(),
source: v.union(v.literal('telegram'), v.literal('web')),
imageStorageId: v.optional(v.id('_storage')),
imageMediaType: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())),
isStreaming: v.optional(v.boolean())
},
returns: v.id('messages'),
handler: async (ctx, args) => {
const messageId = await ctx.db.insert('messages', {
chatId: args.chatId,
role: args.role,
content: args.content,
source: args.source,
imageStorageId: args.imageStorageId,
imageMediaType: args.imageMediaType,
followUpOptions: args.followUpOptions,
createdAt: Date.now(),
isStreaming: args.isStreaming
});
if (args.source === 'web' && args.role === 'user') {
const chat = await ctx.db.get(args.chatId);
if (chat) {
await ctx.db.insert('pendingGenerations', {
userId: chat.userId,
chatId: args.chatId,
userMessage: args.content,
createdAt: Date.now()
});
}
}
return messageId;
}
});
export const update = mutation({
args: {
messageId: v.id('messages'),
content: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())),
isStreaming: v.optional(v.boolean())
},
returns: v.null(),
handler: async (ctx, args) => {
const updates: {
content?: string;
followUpOptions?: string[];
isStreaming?: boolean;
} = {};
if (args.content !== undefined) {
updates.content = args.content;
}
if (args.followUpOptions !== undefined) {
updates.followUpOptions = args.followUpOptions;
}
if (args.isStreaming !== undefined) {
updates.isStreaming = args.isStreaming;
}
await ctx.db.patch(args.messageId, updates);
return null;
}
});
export const getHistoryForAI = query({
args: { chatId: v.id('chats'), limit: v.optional(v.number()) },
returns: v.array(
v.object({
role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string()
})
),
handler: async (ctx, args) => {
const messages = await ctx.db
.query('messages')
.withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId))
.order('asc')
.collect();
const limit = args.limit ?? 50;
const limited = messages.slice(-limit);
return limited.map((m) => ({
role: m.role,
content: m.content
}));
}
});
export const getLastAssistantMessage = query({
args: { chatId: v.id('chats') },
returns: v.union(
v.object({
_id: v.id('messages'),
_creationTime: v.number(),
chatId: v.id('chats'),
role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(),
imageStorageId: v.optional(v.id('_storage')),
imageMediaType: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')),
createdAt: v.number(),
isStreaming: v.optional(v.boolean())
}),
v.null()
),
handler: async (ctx, args) => {
const messages = await ctx.db
.query('messages')
.withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId))
.order('desc')
.collect();
return messages.find((m) => m.role === 'assistant') ?? null;
}
});
export const generateUploadUrl = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
}
});
export const getImageUrls = query({
args: { chatId: v.id('chats') },
returns: v.array(
v.object({
storageId: v.id('_storage'),
mediaType: v.string(),
url: v.union(v.string(), v.null())
})
),
handler: async (ctx, args) => {
const messages = await ctx.db
.query('messages')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
const imageMessages = messages.filter((m) => m.imageStorageId && m.imageMediaType);
const results = [];
for (const msg of imageMessages) {
if (msg.imageStorageId && msg.imageMediaType) {
const url = await ctx.storage.getUrl(msg.imageStorageId);
results.push({
storageId: msg.imageStorageId,
mediaType: msg.imageMediaType,
url
});
}
}
return results;
}
});
export const createWithImage = internalMutation({
args: {
chatId: v.id('chats'),
content: v.string(),
imageStorageId: v.id('_storage'),
imageMediaType: v.string()
},
returns: v.id('messages'),
handler: async (ctx, args) => {
return await ctx.db.insert('messages', {
chatId: args.chatId,
role: 'user' as const,
content: args.content,
source: 'telegram' as const,
imageStorageId: args.imageStorageId,
imageMediaType: args.imageMediaType,
createdAt: Date.now()
});
}
});