feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { topics } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { cancelPendingRename } from '~~/server/utils/renames';
|
||||
import { userEvents } from '~~/server/utils/events';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
|
||||
const success = cancelPendingRename(topicId);
|
||||
if (success) {
|
||||
const res = await db.update(topics).set({
|
||||
renaming: false,
|
||||
}).where(and(eq(topics.id, topicId), eq(topics.userId, event.context.user!.id)));
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid topic',
|
||||
data: {
|
||||
code: 'INVALID_TOPIC',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
userEvents.emit(topicId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import * as z from 'zod';
|
||||
import { renamePrompt } from '~~/prompts';
|
||||
import { generateText } from 'ai';
|
||||
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
|
||||
import { addPendingRename } from '~~/server/utils/renames';
|
||||
import { userEvents } from '~~/server/utils/events';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { type Model } from '~/composables/useModels';
|
||||
import { topics } from '~~/drizzle/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id;
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
modelId: z.string(),
|
||||
providerApiKey: z.string().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
data: {
|
||||
code: 'INVALID_BODY',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const firstMessage = await db.query.messages.findFirst({
|
||||
where: {
|
||||
topicId,
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
with: {
|
||||
parts: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstMessage === undefined) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid topic',
|
||||
data: {
|
||||
code: 'INVALID_TOPIC',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (firstMessage.role !== 'user') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Unimplemented',
|
||||
data: {
|
||||
code: 'INVALID_TOPIC',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { modelId, providerApiKey } = result.data;
|
||||
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: {
|
||||
id: topicId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (topic === undefined || topic.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid topic',
|
||||
data: {
|
||||
code: 'INVALID_TOPIC',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (topic.renaming) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Already renaming',
|
||||
data: {
|
||||
code: 'ALREADY_RENAMING',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const model = await db.query.models.findFirst({
|
||||
where: {
|
||||
id: modelId,
|
||||
userId,
|
||||
},
|
||||
with: {
|
||||
provider: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (model === undefined || model.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid model',
|
||||
data: {
|
||||
code: 'INVALID_MODEL',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const providerDetails = await getProviderDetails(model.provider, providerApiKey, model);
|
||||
if (!providerDetails.ok) {
|
||||
switch (providerDetails.error) {
|
||||
case GatewayFetchError.NoProviderApiKey: {
|
||||
setResponseStatus(event, 400, "No provider API key");
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `${model.provider.type} provider requires an API key`,
|
||||
data: {
|
||||
code: 'NO_PROVIDER_API_KEY',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
case GatewayFetchError.NoProviderBaseUrl: {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider URL',
|
||||
data: {
|
||||
code: 'BAD_PROVIDER_URL',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { gateway } = providerDetails.data;
|
||||
if (gateway === null) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Invalid gateway',
|
||||
data: {
|
||||
code: 'INVALID_GATEWAY',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const abortController = addPendingRename(topicId);
|
||||
event.waitUntil(autoRename(topicId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, firstMessage.content!, userId));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
|
||||
const autoRename = async (
|
||||
topicId: string,
|
||||
abortController: AbortController,
|
||||
model: {
|
||||
gateway: ModelGateway,
|
||||
model: Model,
|
||||
},
|
||||
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined,
|
||||
prompt: string,
|
||||
userId: string,
|
||||
) => {
|
||||
try {
|
||||
await db.update(topics).set({
|
||||
renaming: true,
|
||||
}).where(eq(topics.id, topicId));
|
||||
userEvents.emit(model.model.userId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await generateText({
|
||||
model: model.gateway(model.model.externalId),
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
user: userId,
|
||||
}
|
||||
},
|
||||
system: renamePrompt,
|
||||
prompt,
|
||||
timeout: 90 * 1000,
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
|
||||
let text = response.text;
|
||||
|
||||
if (textTransformer !== undefined) {
|
||||
if (Array.isArray(textTransformer)) {
|
||||
for (const transformer of textTransformer) {
|
||||
text = transformer(text);
|
||||
}
|
||||
} else {
|
||||
text = textTransformer(text);
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(topics).set({
|
||||
renaming: false,
|
||||
name: text,
|
||||
}).where(eq(topics.id, topicId));
|
||||
userEvents.emit(model.model.userId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
name: text,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'topic_updated',
|
||||
payload: {
|
||||
topicId,
|
||||
name: text,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-rename:', error);
|
||||
await db.update(topics).set({
|
||||
renaming: false,
|
||||
}).where(eq(topics.id, topicId));
|
||||
userEvents.emit(model.model.userId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'topic_updated',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
completeRename(topicId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { generations } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { cancelPendingGeneration } from '~~/server/utils/generations';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const { generationId } = event.context.params!;
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: {
|
||||
id: topicId,
|
||||
userId: event.context.user!.id as string,
|
||||
},
|
||||
with: {
|
||||
messages: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!topic) throw createError({ statusCode: 404, message: 'Topic not found' });
|
||||
|
||||
const success = cancelPendingGeneration(generationId!);
|
||||
|
||||
const generation = await db.query.generations.findFirst({
|
||||
where: {
|
||||
id: generationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!generation) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Generation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (generation.status === 'completed') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Generation already completed',
|
||||
});
|
||||
}
|
||||
|
||||
const message = await db.query.messages.findFirst({
|
||||
where: {
|
||||
generationId: generation.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Message not found',
|
||||
});
|
||||
}
|
||||
|
||||
await db.update(generations).set({ status: 'cancelled' });
|
||||
await topicEvents.emit(topicId, {
|
||||
type: 'generation-complete',
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
generationId,
|
||||
}
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Generation not found or already completed',
|
||||
});
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { topics } from "~~/drizzle/schema";
|
||||
import { db } from "~~/server/lib/db";
|
||||
import * as z from 'zod';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
|
||||
const res = await db.delete(topics).where(and(eq(topics.id, topicId), eq(topics.userId, userId)));
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid topic',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { db } from "~~/server/lib/db";
|
||||
import { topicEvents } from "~~/server/utils/events";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
const isSSE = getHeader(event, 'accept')?.includes('text/event-stream') ?? false;
|
||||
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: {
|
||||
id: topicId,
|
||||
userId,
|
||||
},
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
with: {
|
||||
parts: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
with: {
|
||||
toolCall: true,
|
||||
}
|
||||
},
|
||||
attachments: {
|
||||
with: {
|
||||
file: true,
|
||||
}
|
||||
},
|
||||
generation: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
console.log("topic not found");
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Not Found',
|
||||
});
|
||||
}
|
||||
|
||||
if (isSSE === false) {
|
||||
return topic;
|
||||
}
|
||||
|
||||
setHeader(event, 'Content-Type', 'text/event-stream');
|
||||
setHeader(event, 'Cache-Control', 'no-cache');
|
||||
setHeader(event, 'Connection', 'keep-alive');
|
||||
|
||||
const { lastUpdate, count } = getQuery(event);
|
||||
const serverLastUpdate = topic.messages.at(-1)?.updatedAt;
|
||||
const serverCount = topic.messages.length;
|
||||
|
||||
// TODO: there is potentially a race condition here where the client could
|
||||
// connect at milisecond 87, and the last database write was at 0 (there
|
||||
// is a write every 100ms for some operations), if there were tokens sent
|
||||
// at 20 40 and 60, those tokens are lost to the client.
|
||||
let streamController: ReadableStreamDefaultController;
|
||||
let interval: NodeJS.Timeout;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
controller.enqueue(`:connected\n\n`);
|
||||
|
||||
if ((lastUpdate && count) && (lastUpdate === serverLastUpdate?.toISOString() && count === serverCount)) {
|
||||
controller.enqueue(':already_synced\n\n')
|
||||
} else {
|
||||
controller.enqueue(`data: ${JSON.stringify({
|
||||
type: 'initial_state',
|
||||
payload: topic
|
||||
})}\n\n`);
|
||||
}
|
||||
|
||||
interval = setInterval(() => {
|
||||
controller.enqueue(`:heartbeat\n\n`);
|
||||
}, 15000);
|
||||
|
||||
topicEvents.subscribe(topicId, streamController);
|
||||
},
|
||||
cancel() {
|
||||
topicEvents.unsubscribe(topicId, streamController);
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
return sendStream(event, stream);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { topics } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id;
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
const { name } = result.data;
|
||||
|
||||
const res = await db.update(topics)
|
||||
.set({
|
||||
name,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(topics.id, topicId),
|
||||
eq(topics.userId, userId),
|
||||
)
|
||||
);
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid topic',
|
||||
});
|
||||
}
|
||||
|
||||
userEvents.emit(userId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
name,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'topic_updated',
|
||||
payload: {
|
||||
topicId,
|
||||
name,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { modelMessageSchema } from 'ai';
|
||||
import * as z from 'zod';
|
||||
import { attachments, messages } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const result = await readValidatedBody(event, z.object({
|
||||
message: z.intersection(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
fileIds: z.array(z.string()).optional(),
|
||||
}),
|
||||
modelMessageSchema
|
||||
),
|
||||
}).safeParse);
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Bad Request',
|
||||
message: result.error.issues.map(issue => issue.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { message } = result.data;
|
||||
|
||||
await db.insert(messages).values({
|
||||
// @ts-ignore - drizzle bug
|
||||
id: message.id,
|
||||
userId,
|
||||
topicId,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
});
|
||||
|
||||
for (const fileId of message.fileIds || []) {
|
||||
await db.insert(attachments).values({
|
||||
userId,
|
||||
topicId,
|
||||
messageId: message.id,
|
||||
fileId,
|
||||
});
|
||||
}
|
||||
|
||||
const usermessage = await db.query.messages.findFirst({
|
||||
where: {
|
||||
id: message.id,
|
||||
},
|
||||
with: {
|
||||
attachments: {
|
||||
with: {
|
||||
file: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!usermessage) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to insert message',
|
||||
message: 'Failed to insert message',
|
||||
});
|
||||
}
|
||||
topicEvents.emit(topicId, { type: 'MESSAGE_CREATED', payload: usermessage });
|
||||
|
||||
return {
|
||||
ok: true
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as z from 'zod';
|
||||
import { topics } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
agentId: z.string(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
const { id, name, agentId } = result.data;
|
||||
|
||||
const res = await db.insert(topics).values({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
agentId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Failed to create topic',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
Reference in New Issue
Block a user