initial commit

This commit is contained in:
Zoe
2026-01-11 05:04:29 -06:00
commit 0877cc10bd
65 changed files with 5009 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import { eq } from "drizzle-orm";
import { agents } from "~~/db/schema";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const { id } = event.context.params!;
const [row] = await db.select().from(agents).where(eq(agents.id, id));
if (row === undefined || row.userId !== event.context.user.id) {
throw createError({ statusCode: 404, statusMessage: 'Agent not found' });
}
const { name, systemPrompt, imageUrl } = await readBody(event);
const updateObject: Partial<typeof row> = {};
if (name !== undefined) updateObject.name = name;
if (systemPrompt !== undefined) updateObject.systemPrompt = systemPrompt;
if (imageUrl !== undefined) updateObject.imageUrl = imageUrl;
if (Object.keys(updateObject).length === 0) {
throw createError({ statusCode: 400, statusMessage: 'No update data provided' });
}
const [agent] = await db.update(agents).set(updateObject).where(eq(agents.id, id)).returning();
return agent;
});
+11
View File
@@ -0,0 +1,11 @@
import { agents } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const rows = await db.select().from(agents);
return rows;
});
+17
View File
@@ -0,0 +1,17 @@
import { agents } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const { name, systemPrompt, imageUrl } = await readBody(event);
const userId = event.context.user.id;
if (!name || !systemPrompt) {
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' });
}
const [inserted] = await db.insert(agents).values({ name, userId, systemPrompt, imageUrl }).returning();
return inserted;
});
+5
View File
@@ -0,0 +1,5 @@
import { auth } from "~~/lib/auth";
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event));
});
+33
View File
@@ -0,0 +1,33 @@
import { protectRoute } from '~~/server/utils/auth';
import { registerPendingGeneration } from '~~/server/utils/generation';
import type { GenerateRequestBody } from '~~/server/types/chat';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const body = await readBody(event) as GenerateRequestBody;
const { topicId, messages } = body;
if (!topicId || !messages) {
throw createError({
statusCode: 400,
statusMessage: 'Missing required fields: topicId and messages'
});
}
if (messages.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Messages array cannot be empty'
});
}
const generationId = `gen_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
registerPendingGeneration(event.context.user.id, generationId, topicId, messages);
return {
generationId,
status: 'pending'
};
});
+52
View File
@@ -0,0 +1,52 @@
import { protectRoute } from '~~/server/utils/auth';
import { getPendingGeneration, getActiveGeneration, isGenerationActive } from '~~/server/utils/generation';
import type { GenerationStatus } from '~~/server/types/chat';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const generationId = getRouterParam(event, 'id');
if (!generationId) {
throw createError({
statusCode: 400,
statusMessage: 'Missing generation ID'
});
}
const pendingGeneration = getPendingGeneration(generationId);
const isActive = isGenerationActive(generationId);
const activeGeneration = getActiveGeneration(generationId);
if (!pendingGeneration && !activeGeneration) {
throw createError({
statusCode: 404,
statusMessage: 'Generation not found'
});
}
if (pendingGeneration) {
const status: GenerationStatus = {
generationId,
status: 'pending',
topicId: pendingGeneration.topicId
};
return status;
}
if (isActive && activeGeneration) {
const status: GenerationStatus = {
generationId,
status: 'active',
content: activeGeneration.content,
topicId: activeGeneration.topicId
};
return status;
}
const status: GenerationStatus = {
generationId,
status: 'completed'
};
return status;
});
+67
View File
@@ -0,0 +1,67 @@
import { protectRoute } from '~~/server/utils/auth';
import { getPendingGeneration, startGeneration, addClientToGeneration, removeClientFromGeneration, sendToClient } from '~~/server/utils/generation';
import { eventHandler, setHeader, setResponseStatus } from 'h3';
import { generations, messages } from '~~/db/schema';
import { eq } from 'drizzle-orm';
export default eventHandler(async (event) => {
await protectRoute(event);
const generationId = getRouterParam(event, 'id');
if (!generationId) {
throw createError({
statusCode: 400,
statusMessage: 'Missing generation ID'
});
}
const pendingGeneration = getPendingGeneration(generationId);
if (pendingGeneration && pendingGeneration.expired) {
throw createError({
statusCode: 410,
statusMessage: 'Generation expired - no client connected within 60 seconds'
});
}
setHeader(event, 'Content-Type', 'text/event-stream');
setHeader(event, 'Cache-Control', 'no-cache');
setHeader(event, 'Connection', 'keep-alive');
setHeader(event, 'X-Accel-Buffering', 'no');
setResponseStatus(event, 200);
const shouldStartGeneration = pendingGeneration;
try {
const stream = new ReadableStream({
async start(controller) {
addClientToGeneration(generationId, controller);
if (shouldStartGeneration) {
startGeneration(generationId, controller);
} else {
const generation = await useDrizzle().select().from(generations).where(eq(generations.id, getRouterParam(event, 'id')!))
if (!generation) throw createError({ statusCode: 404, statusMessage: 'Generation not found' });
const message = await useDrizzle().select().from(messages).where(eq(messages.id, generation[0].messageId!))
if (!message) throw createError({ statusCode: 404, statusMessage: 'Message not found' });
sendToClient(controller, {
type: 'complete',
data: message[0].content
});
controller.close();
}
event.node.req.on('close', () => {
removeClientFromGeneration(generationId, controller);
});
}
});
return sendStream(event, stream);
} catch (error) {
// assume it failed because the generation is complete so try to send the message
}
});
+23
View File
@@ -0,0 +1,23 @@
import { and, desc, eq } from "drizzle-orm";
import { messages, topics } from "~~/db/schema";
import type { Message, Topic } from '~~/types'
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const rows = await db.select().from(topics).where(and(eq(topics.userId, event.context.user.id), eq(topics.id, getRouterParam(event, 'id')!)));
if (rows.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Topic not found'
});
}
const topic = rows[0] as Topic & { messages: Message[] };
topic.messages = await db.select().from(messages).where(eq(messages.topicId, topic.id)).orderBy(desc(messages.createdAt));
return topic;
});
+11
View File
@@ -0,0 +1,11 @@
import { topics } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const rows = await db.select().from(topics);
return rows;
});
+16
View File
@@ -0,0 +1,16 @@
import { topics } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const body = await readBody(event);
const { agentId, name } = body;
if (!agentId || !name) {
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' });
}
const [inserted] = await db.insert(topics).values({ userId: event.context.user.id, agentId, name }).returning();
return inserted;
});
+14
View File
@@ -0,0 +1,14 @@
import { consola } from 'consola';
export default defineNitroPlugin(async () => {
consola.info('Connecting to database...');
try {
const db = useDrizzle();
await db.execute('SELECT 1');
consola.success('Connected to database!');
} catch (e) {
consola.error('Connection to database failed!');
process.kill(process.pid);
}
});
+26
View File
@@ -0,0 +1,26 @@
export type MessageType = 'system' | 'agent' | 'user';
export interface ChatMessage {
type: MessageType;
message: string;
}
export interface GenerateRequestBody {
topicId: string;
messages: ChatMessage[];
}
export interface GenerationStreamEvent {
type: 'token' | 'complete' | 'error';
data: string | object | null;
}
export interface GenerationStatus {
generationId: string;
status: 'pending' | 'active' | 'completed' | 'error';
content?: string;
topicId?: string;
model?: string;
tokensGenerated?: number;
tokensUsedThinking?: number;
}
+15
View File
@@ -0,0 +1,15 @@
import { H3Event } from 'h3';
import { auth } from '~~/lib/auth';
export const protectRoute = async (event: H3Event) => {
const sessionData = await auth.api.getSession(event);
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
event.context.user = sessionData.user;
};
+11
View File
@@ -0,0 +1,11 @@
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "~~/db/schema";
export const useDrizzle = () => {
return drizzle(process.env.DATABASE_URL!)
}
export const tables = schema;
export const UserInsert = schema.user.$inferInsert;
export type UserRegisterType = Omit<typeof UserInsert, "createdAt" | "updatedAt" | "id" | "emailVerified">;
+230
View File
@@ -0,0 +1,230 @@
import { useDrizzle } from '~~/server/utils/drizzle';
import { generations, messages as messages_drizzle } from '~~/db/schema';
import { type GenerationStreamEvent, type ChatMessage, type MessageType } from '~~/server/types/chat';
import { eq } from 'drizzle-orm';
interface ActiveGeneration {
userId: string;
topicId: string;
messages: ChatMessage[];
content: string;
clients: Set<ReadableStreamDefaultController<Uint8Array>>;
complete: boolean;
}
interface PendingGeneration {
generationId: string;
userId: string;
topicId: string;
messages: ChatMessage[];
timeout: NodeJS.Timeout;
expired: boolean;
}
const activeGenerations = new Map<string, ActiveGeneration>();
const pendingGenerations = new Map<string, PendingGeneration>();
export const getActiveGeneration = (generationId: string): ActiveGeneration | undefined => {
return activeGenerations.get(generationId);
};
export const getPendingGeneration = (generationId: string): PendingGeneration | undefined => {
return pendingGenerations.get(generationId);
};
export const isGenerationActive = (generationId: string): boolean => {
return activeGenerations.has(generationId);
};
export const isGenerationPending = (generationId: string): boolean => {
return pendingGenerations.has(generationId);
};
export const addClientToGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): boolean => {
const generation = activeGenerations.get(generationId);
if (!generation) {
return false;
}
generation.clients.add(controller);
return true;
};
export const removeClientFromGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): void => {
const generation = activeGenerations.get(generationId);
if (generation) {
generation.clients.delete(controller);
if (generation.clients.size === 0 && generation.complete) {
activeGenerations.delete(generationId);
}
}
};
const sendToClients = (generation: ActiveGeneration, event: GenerationStreamEvent): void => {
for (const client of generation.clients) {
sendToClient(client, event);
}
};
export const sendToClient = (client: ReadableStreamDefaultController<Uint8Array>, event: GenerationStreamEvent): void => {
const data = JSON.stringify(event);
const encoder = new TextEncoder();
try {
client.enqueue(encoder.encode(`${data}\n`));
} catch (error) {
console.error('Failed to send to client:', error);
}
}
const buildPrompt = (messages: ChatMessage[]): string => {
return messages
.map((msg: ChatMessage) => {
const roleMap: Record<MessageType, string> = {
system: 'System',
user: 'User',
agent: 'Assistant'
};
return `${roleMap[msg.type]}: ${msg.message}`;
})
.join('\n\n');
};
const db = useDrizzle();
export const startGeneration = async (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): Promise<void> => {
const pending = pendingGenerations.get(generationId);
if (!pending) {
console.error(`Generation ${generationId} not found in pending generations`);
return;
}
clearTimeout(pending.timeout);
pendingGenerations.delete(generationId);
const { userId, topicId, messages } = pending;
const prompt = buildPrompt(messages);
const generation: ActiveGeneration = {
userId,
topicId,
messages,
content: '',
clients: new Set([controller]),
complete: false
};
await db.insert(generations).values({
id: generationId,
userId,
topicId,
messageId: null
});
activeGenerations.set(generationId, generation);
try {
const dummyResponse = generateDummyResponse(prompt, messages);
const tokens = dummyResponse.split(' ');
for (const token of tokens) {
await new Promise(resolve => setTimeout(resolve, 50));
generation.content += token + ' ';
sendToClients(generation, {
type: 'token',
data: token + ' '
});
}
const [message] = await db.insert(messages_drizzle).values({
topicId: generation.topicId,
userId: generation.userId,
isUser: false,
content: generation.content.trim(),
model: 'dummy-model-v1',
tokensGenerated: tokens.length,
tokensUsedThinking: 0
}).returning();
generation.complete = true;
sendToClients(generation, {
type: 'complete',
data: message
});
if (generation.clients.size === 0) {
activeGenerations.delete(generationId);
} else {
generation.clients.forEach((client) => {
try {
client.close();
} catch {
}
});
activeGenerations.delete(generationId);
}
await db.update(generations).set({
messageId: message.id
}).where(eq(generations.id, generationId));
} catch (error) {
console.error('Generation failed:', error);
sendToClients(generation, {
type: 'error',
data: error instanceof Error ? error.message : 'Unknown error'
});
await db.delete(generations).where(eq(generations.id, generationId));
activeGenerations.delete(generationId);
}
};
export const registerPendingGeneration = (userId: string, generationId: string, topicId: string, messages: ChatMessage[]): void => {
const timeout = setTimeout(() => {
const gen = pendingGenerations.get(generationId)
if (gen) gen.expired = true;
console.log(`Generation ${generationId} expired - no client connected within 60 seconds`);
}, 60000);
pendingGenerations.set(generationId, {
generationId,
userId,
topicId,
messages,
timeout,
expired: false
});
console.log(`Registered pending generation ${generationId}, waiting for client connection...`);
};
const generateDummyResponse = (prompt: string, messages: ChatMessage[]): string => {
const responses = [
"This is a simulated response to your prompt. In a real implementation, this would be generated by an AI model like GPT-4 or Claude."
+ " I'm processing your message about: " + prompt.substring(0, 50) + "... "
+ "This dummy generation demonstrates the streaming and background save functionality.",
"I understand your query. This is a placeholder response that simulates AI-generated content."
+ " The system will continue generating this response even if you close the tab, and it will"
+ " automatically save to the database when complete.",
"Here's a simulated AI response. This demonstrates two key features:"
+ " 1) The generation continues in the background even if you disconnect,"
+ " 2) The complete response is automatically saved to the database without requiring"
+ " a separate update request from the client."
];
const lastUserMessage = messages[messages.length - 1]?.message.toLowerCase() || '';
if (lastUserMessage.includes('hello') || lastUserMessage.includes('hi')) {
return "Hello! I'm a dummy AI assistant. This is a simulated response to your greeting."
+ " In production, this would be replaced with actual AI-generated content from an LLM provider.";
}
return responses[Math.floor(Math.random() * responses.length)];
};