feat: ditch triplit, move to postgresql + drizzle orm

This commit is contained in:
Zoe
2026-04-08 17:03:07 -05:00
parent c341c96798
commit e2e3ac6e86
121 changed files with 6680 additions and 4373 deletions
+34
View File
@@ -0,0 +1,34 @@
import { and, eq } from "drizzle-orm";
import { agents } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
import { userEvents } from "~~/server/utils/events";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const agentId = getRouterParam(event, 'id')!;
const res = await db.delete(agents).where(
and(
eq(agents.id, agentId),
eq(agents.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid agent',
});
}
userEvents.emit(userId, 'agents', {
op: 'delete',
payload: {
id: agentId,
},
});
return { ok: true };
});
+66
View File
@@ -0,0 +1,66 @@
import { and, eq } from 'drizzle-orm';
import * as z from 'zod';
import { agents } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { userEvents } from '~~/server/utils/events';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().optional(),
systemPrompt: z.string().nullable().optional(),
imageUrl: z.string().optional(),
defaultModelId: z.string().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const agentId = getRouterParam(event, 'id')!;
const { name, systemPrompt, imageUrl, defaultModelId } = result.data;
const res = await db.update(agents)
.set({
name,
systemPrompt,
imageUrl,
defaultModelId,
})
.where(
and(
eq(agents.id, agentId),
eq(agents.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid agent',
});
}
userEvents.emit(userId, 'agents', {
op: 'update',
payload: {
id: agentId,
name,
systemPrompt,
imageUrl,
defaultModelId,
},
});
return { ok: true };
});
+44
View File
@@ -0,0 +1,44 @@
import { createInsertSchema } from 'drizzle-orm/zod';
import { db } from '~~/server/lib/db';
import { agents } from '~~/drizzle/schema';
import { userEvents } from '~~/server/utils/events';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event,
createInsertSchema(agents).safeParse
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
if (!result.data.userId) {
result.data.userId = userId;
}
if (result.data.userId !== userId) {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
data: {
code: 'FORBIDDEN',
ok: false,
}
});
}
await db.insert(agents).values(result.data).onConflictDoNothing();
userEvents.emit(userId, 'agents', {
op: 'create',
payload: result.data,
});
return { ok: true };
})
+37
View File
@@ -0,0 +1,37 @@
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 as string;
const result = await getValidatedQuery(event, z.object({
page: z.number().optional(),
limit: z.number().optional(),
}).safeParse)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const agents = await db.query.agents.findMany({
where: {
userId,
},
orderBy: {
createdAt: 'asc',
},
with: {
topics: {
orderBy: {
createdAt: 'desc',
},
}
}
});
return agents;
})
-142
View File
@@ -1,142 +0,0 @@
import * as z from 'zod';
import { httpClient } from '~~/server/lib/triplit';
import { renamePrompt } from '~~/prompts';
import { schema } from '~~/triplit/schema';
import { type Entity } from '@triplit/client';
import { generateText } from 'ai';
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
import { addPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert';
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(),
prompt: 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 { modelId, prompt, providerApiKey } = result.data;
const model = await httpClient.fetchOne(httpClient.query('models').Where('id', '=', modelId).Include('provider'));
if (model === null || model.providerId !== model.providerId || model.userId !== userId || model.provider === null) {
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 [renameId, pendingRename] = addPendingRename(topicId);
event.waitUntil(autoRename(topicId, renameId, pendingRename.abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt));
return {
renameId,
ok: true,
};
});
const autoRename = async (
topicId: string,
renameId: string,
abortController: AbortController,
model: {
gateway: ModelGateway,
model: Entity<typeof schema, 'models'>,
},
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined,
prompt: string,
) => {
try {
const response = await generateText({
model: model.gateway(model.model.externalId),
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 httpClient.update('topics', topicId, {
name: text,
});
} catch (error) {
console.error('Failed to auto-rename:', error);
} finally {
completeRename(renameId);
await httpClient.update('topics', topicId, {
renaming: false
});
}
}
-42
View File
@@ -1,42 +0,0 @@
import * as z from 'zod';
import { httpClient } from '~~/server/lib/triplit';
import { cancelPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const body = await readValidatedBody(event, (body) =>
z
.object({
renameId: z.string(),
})
.safeParse(body),
);
if (!body.success) {
throw createError({
statusCode: 400,
message: 'Invalid rename ID',
});
}
const { renameId } = body.data;
assert(renameId);
const [success, pendingRename] = cancelPendingRename(renameId);
if (success) {
const topic = await httpClient.fetchOne(httpClient.query('topics').Where('id', '=', pendingRename!.topicId));
if (topic !== null && topic.renaming) {
await httpClient.update('topics', topic.id, {
renaming: false,
});
}
return {
ok: true,
};
}
return {
ok: false,
};
});
@@ -1,26 +0,0 @@
import { httpClient } from '~~/server/lib/triplit';
import { cancelPendingGeneration } from '~~/server/utils/generations';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const { generationId } = event.context.params!;
const success = cancelPendingGeneration(generationId!);
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
if (generation !== null && generation.status === 'pending') {
await httpClient.update('generations', generationId!, {
status: 'cancelled',
});
}
if (!success) {
throw createError({
statusCode: 400,
message: 'Generation not found or already completed',
});
}
return 'ok';
});
-919
View File
@@ -1,919 +0,0 @@
import type { Entity } from '@triplit/client';
import { type ModelMessage, modelMessageSchema, streamText, type StreamTextTransform, tool } from 'ai';
import { promises as fs } from 'fs';
import { glob } from 'glob';
import { nanoid } from 'nanoid';
import path from 'path';
import * as z from 'zod';
import { httpClient } from '~~/server/lib/triplit';
import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations';
import type { schema } from '~~/triplit/schema';
import { spawn } from 'child_process';
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
import { assert } from '~~/utils/assert';
export const messagesSchema = z.array(modelMessageSchema);
export default defineEventHandler(async (event) => {
await protectRoute(event);
const result = await readValidatedBody(event, (body) =>
z
.object({
messages: messagesSchema.min(1),
topicId: z.string(),
parentMessageId: z.string().nullable(),
model: z.object({
providerId: z.string(),
modelId: z.string(),
args: z.record(z.string(), z.any()),
}),
providerApiKey: z.string().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const userId = event.context.user!.id;
const { messages, topicId, parentMessageId, model: { modelId, providerId, args }, providerApiKey } = result.data;
const [provider, model, existingPendingGenerations] = await Promise.all([
httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId)),
httpClient.fetchOne(httpClient.query('models').Where('id', '=', modelId)),
httpClient.fetchOne(
httpClient.query('generations').Where('topicId', '=', topicId).Where('status', '=', 'pending'),
),
]);
if (provider === null || provider.userId !== userId) {
throw createError({
statusCode: 400,
message: 'Invalid provider',
});
}
if (model === null || model.providerId !== model.providerId || model.userId !== userId) {
throw createError({
statusCode: 400,
message: 'Invalid model',
});
}
if (existingPendingGenerations !== null) {
throw createError({
statusCode: 400,
message: 'There cannot be more than one active generation per topic',
});
}
const providerDetails = await getProviderDetails(provider, providerApiKey, model);
if (!providerDetails.ok) {
switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: {
throw createError({
statusCode: 400,
message: `${provider.type} provider requires an API key`,
});
}
case GatewayFetchError.NoProviderBaseUrl: {
throw createError({
statusCode: 400,
message: 'Invalid provider URL',
});
}
}
}
const { gateway } = providerDetails.data;
if (gateway === null) {
throw createError({
statusCode: 500,
statusMessage: 'Invalid gateway',
data: {
code: 'INVALID_GATEWAY',
ok: false,
}
});
}
const generationId = nanoid();
// TODO: do these inserts on the client so that the feedback is instant
const message = await httpClient.insert('messages', {
topicId,
userId,
focusedIndex: parentMessageId ? null : 0,
generationId,
parentMessageId,
content: '',
role: 'assistant',
});
await httpClient.insert('generations', {
id: generationId,
userId,
topicId,
modelId: model.externalId,
status: 'pending',
messageId: message.id,
});
let logFile: fs.FileHandle | undefined;
let logMessage: ((message: string) => void) | undefined;
if (process.env.GENERATION_DEBUG) {
if (process.env.LOG_DIR) {
await fs.mkdir(process.env.LOG_DIR!, { recursive: true });
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
logMessage = (message: string) => {
logFile!.write(message + '\n');
};
} else {
console.warn('Generation debug logging is enabled but GENERATION_DEBUG is not set');
}
}
event.waitUntil(
generateResponse(message, { gateway: gateway.gateway, model, parameters: args }, generationId, userId, topicId, messages, gateway.streamTransformer, logMessage, logFile),
);
return {
generationId,
messageId: message.id,
};
});
const INTERNAL_ERROR = 'An internal error occurred';
// todo message takes in variadics like console.log
const todo = (...args: any[]) => {
console.error('TODO', ...args);
throw new Error('TODO');
};
const evalPython = async (code: string) => {
// This is the wrapper logic from above, minified or stored as a string
// Or you can save the wrapper script to a file and call that.
const wrapper = `
import ast
import sys
code = sys.stdin.read()
tree = ast.parse(code)
last_node = tree.body[-1] if tree.body else None
namespace = {}
if len(tree.body) > 1:
exec(compile(ast.Module(tree.body[:-1], []), "<ast>", "exec"), namespace)
if isinstance(last_node, ast.Expr):
res = eval(compile(ast.Expression(last_node.value), "<ast>", "eval"), namespace)
if res is not None: print(res)
elif last_node:
exec(compile(ast.Module([last_node], []), "<ast>", "exec"), namespace)
`.trim();
return new Promise<string>((resolve, reject) => {
const child = spawn('python3', ['-c', wrapper]);
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
output += data.toString();
});
child.stderr.on('data', (data) => {
errorOutput += data.toString();
});
child.on('close', (exitCode) => {
if (exitCode !== 0) {
reject(errorOutput || `Exit code ${exitCode}`);
} else {
resolve(output.trim());
}
});
// Send the agent's code to the wrapper via stdin
child.stdin.write(code);
child.stdin.end();
});
};
// TODO: obviously come up with a better way for the user to define their own tools
const { listDirectoryTool, globTool, readFileTool, readFilesTool, fetchUrlTool, pythonTool, bashTool } = {
listDirectoryTool: tool({
description: 'Lists the contents of a directory',
inputSchema: z.object({
path: z.string(),
}),
outputSchema: z.object({
files: z.array(z.object({ name: z.string(), type: z.string() })),
}),
execute: async ({ path }) => {
const rawFiles = await fs.readdir(path, { withFileTypes: true });
const files = rawFiles.map((file) => ({
name: file.name,
type: file.isFile() ? 'file' : 'directory',
}));
return {
files,
};
},
}),
globTool: tool({
description: 'Lists files matching a glob pattern',
inputSchema: z.object({
pattern: z.string(),
}),
outputSchema: z.object({
files: z.array(z.object({ path: z.string(), type: z.string() })),
}),
execute: async ({ pattern }) => {
const rawFiles = await glob(pattern, { withFileTypes: true });
const files = rawFiles.map((file) => ({
path: file.parentPath + '/' + file.name,
type: file.isFile() ? 'file' : 'directory',
}));
return {
files,
};
},
}),
readFileTool: tool({
description: 'Reads the contents of a file',
inputSchema: z.object({
path: z.string(),
}),
outputSchema: z.object({
path: z.string(),
content: z.string(),
}),
execute: async ({ path }) => {
const file = await fs.readFile(path);
return {
path,
content: file.toString(),
};
},
}),
readFilesTool: tool({
description: 'Reads the contents of multiple files',
inputSchema: z.object({
paths: z.array(z.string()).describe('The file paths to read'),
}),
outputSchema: z.object({
files: z.array(
z.object({
path: z.string(),
content: z.string(),
}),
),
}),
execute: async ({ paths }) => {
const files = await Promise.all(
paths.map(async (path) => {
const file = await fs.readFile(path);
return {
path: path,
content: file.toString(),
};
}),
);
return {
files,
};
},
}),
fetchUrlTool: tool({
description: 'Fetches the content of a URL',
inputSchema: z.object({
url: z.string(),
}),
outputSchema: z.object({
content: z.string(),
}),
execute: async ({ url }) => {
const response = await fetch(url);
const content = await response.text();
return {
content,
};
},
}),
pythonTool: tool({
description: 'Executes a Python code snippet',
inputSchema: z.object({
code: z.string(),
}),
outputSchema: z.object({
output: z.string(),
}),
execute: async ({ code }) => {
const output = await evalPython(code);
return {
output,
};
},
}),
bashTool: tool({
description: 'Executes a Bash command',
inputSchema: z.object({
code: z.string(),
}),
outputSchema: z.object({
output: z.string(),
}),
execute: async ({ code }) => {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
async function runCommand(command: string) {
const { stdout, stderr } = await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
return stderr;
} else {
return stdout;
}
}
const output = await runCommand(code);
return {
output,
};
},
}),
}
async function generateResponse(
message: Entity<typeof schema, 'messages'>,
model: {
gateway: ModelGateway,
model: Entity<typeof schema, 'models'>,
parameters: Record<string, any>,
},
generationId: string,
userId: string,
topicId: string,
messages: ModelMessage[],
streamTransoforms: StreamTextTransform<{}> | StreamTextTransform<{}>[] | undefined,
log?: (message: string) => void,
logFile?: fs.FileHandle,
) {
const controller = new AbortController();
addPendingGeneration(generationId, controller);
let requestStart = undefined;
let ttft = undefined;
const activeParts = new Map<string, { id: string; accumulatedContent: string; providerOptions?: any }>();
const activeToolCalls = new Map<string, void>();
// TODO: somehow let the user turn on and off tools
const tools = {
// writeFile: tool({
// inputSchema: z.object({
// path: z.string(),
// content: z.string(),
// }),
// outputSchema: z.object({
// success: z.boolean(),
// }),
// execute: async ({ path, content }) => {
// await fs.writeFile(path, content);
// return {
// success: true,
// };
// }
// }),
listDirectory: listDirectoryTool,
glob: globTool,
readFile: readFileTool,
readFiles: readFilesTool,
// fetchUrl: fetchUrlTool,
python: pythonTool,
bash: bashTool,
};
const response = streamText({
model: model.gateway(model.model.externalId),
messages,
providerOptions: {
openrouter: {
debug: {
echo_upstream_body: true,
},
user: userId,
}
},
experimental_transform: streamTransoforms,
// a little trick that makes it so that the stream doesnt stop because of tool calls, and will continue an unbounded amount of time and steps
stopWhen: [],
tools: [...model.model.attributes.capabilities].includes('tools') ? tools : undefined,
onError: async (error: any) => {
console.error('generation error', error);
log?.(error);
// TODO: the docs say "The stream processing will pause until the callback promise is resolved." Suggesting that this error might not be fatal?
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
for (const toolCallId of activeToolCalls.keys()) {
await httpClient.update('tool_calls', toolCallId, {
status: 'failed',
error: {
type: 'text',
value: 'An unknown error occurred',
},
});
activeToolCalls.delete(toolCallId);
}
let errValue = error.message || error;
if (typeof errValue === 'object') {
errValue = JSON.stringify(errValue, null, 2);
}
await httpClient.update('generations', generationId, {
status: 'failed',
error: errValue,
});
},
abortSignal: controller.signal,
});
let curStepIdx = -1;
let key, part, type;
const pendingUpdates = new Map<string, NodeJS.Timeout>();
const TARGET_UPDATES_PER_SECOND = 24;
const scheduleUpdate = (key: string) => {
const part = activeParts.get(key);
if (!part || pendingUpdates.has(part.id)) return;
pendingUpdates.set(part.id, setTimeout(async () => {
const currentPart = activeParts.get(key);
// Only update if the part is still active and we haven't deleted it at 'text-end'
if (currentPart) {
try {
await httpClient.update('message_parts', currentPart.id, {
content: currentPart.accumulatedContent,
providerOptions: currentPart.providerOptions,
lastUpdatedAt: new Date(),
});
} catch (error) {
// the update failed, but it doesnt matter because the full message will be updated on step finish
console.warn('Failed to update message part', error);
}
}
pendingUpdates.delete(part.id);
}, 1000 / TARGET_UPDATES_PER_SECOND));
};
try {
for await (const token of response.fullStream) {
log?.(JSON.stringify(token, null, 2));
switch (token.type) {
case 'start': {
requestStart = performance.now();
} break;
case 'start-step': {
curStepIdx++;
} break;
case 'tool-input-start': {
key = `tool-call-${curStepIdx}`;
const toolCallId = token.id;
part = await httpClient.insert('message_parts', {
topicId,
messageId: message.id,
userId,
toolCallId,
type: 'tool-call',
content: '',
finished: false,
createdAt: new Date(),
lastUpdatedAt: new Date(),
});
await httpClient.insert('tool_calls', {
id: toolCallId,
userId: userId,
toolName: token.toolName,
status: 'pending',
input: null,
output: null,
error: null,
createdAt: new Date(),
});
activeToolCalls.set(toolCallId);
activeParts.set(key, { id: part.id, accumulatedContent: '' });
} break;
case 'text-start':
case 'reasoning-start': {
type = token.type.split('-')[0];
key = `${type}-${curStepIdx}`;
part = await httpClient.insert('message_parts', {
topicId,
messageId: message.id,
userId,
type: type as 'text' | 'reasoning',
content: '',
finished: false,
createdAt: new Date(),
lastUpdatedAt: new Date(),
});
activeParts.set(key, { id: part.id, accumulatedContent: '' });
} break;
case 'text-delta':
case 'reasoning-delta': {
if (ttft === undefined) {
ttft = performance.now() - requestStart!;
}
type = token.type.split('-')[0] as 'text' | 'reasoning';
key = `${type}-${curStepIdx}`;
part = activeParts.get(key);
if (part === undefined) {
console.error('Received delta without a start');
break;
}
let shouldUpdate = false;
// TODO: we should potentially merge providerOptions, but for now, just overwrite them
if (token.providerMetadata !== undefined) {
shouldUpdate = true;
part.providerOptions = token.providerMetadata;
}
// OpenRouter sometimes puts [REDACTED] in thinking if reasoning is encrypted, so we need to remove it and hide it;
// do not trim or else we lose intentional whitespace and newlines potentially breaking the UI and having words comebined e.g. "the" "\n\n" "assistant" would become "theassistant"
const text = token.text.replaceAll('[REDACTED]', '');
if (text !== '') {
shouldUpdate = true;
part.accumulatedContent += token.text;
}
if (shouldUpdate) {
scheduleUpdate(key);
}
}
break;
case 'text-end':
case 'reasoning-end': {
type = token.type.split('-')[0];
key = `${type}-${curStepIdx}`;
part = activeParts.get(key);
if (part === undefined) {
console.error('Received end without a start');
break;
}
activeParts.delete(key);
if (part.accumulatedContent === '' && part.providerOptions === undefined) {
// completely empty, delete it
await httpClient.delete('message_parts', part.id);
break;
}
await httpClient.update('message_parts', part.id, {
content: part.accumulatedContent,
providerOptions: part.providerOptions,
finished: true,
lastUpdatedAt: new Date(),
});
} break;
case 'tool-call': {
let inputType: 'text' | 'json' = 'text';
let inputValue: string = '';
switch (typeof token.input) {
case 'string':
inputType = 'text';
inputValue = token.input;
break;
case 'object':
inputType = 'json';
inputValue = JSON.stringify(token.input);
break;
default:
console.error('Unknown input type', token.input);
break;
}
if (activeToolCalls.has(token.toolCallId)) {
await httpClient.update('tool_calls', token.toolCallId, {
status: 'pending',
input: {
type: inputType,
value: inputValue,
},
});
} else {
key = `tool-call-${curStepIdx}`;
const toolCallId = token.toolCallId;
part = await httpClient.insert('message_parts', {
topicId,
messageId: message.id,
userId,
toolCallId,
type: 'tool-call',
content: '',
finished: false,
createdAt: new Date(),
lastUpdatedAt: new Date(),
});
await httpClient.insert('tool_calls', {
id: toolCallId,
userId: userId,
toolName: token.toolName,
status: 'pending',
input: {
type: inputType,
value: inputValue,
},
output: null,
error: null,
createdAt: new Date(),
});
activeToolCalls.set(toolCallId);
activeParts.set(key, { id: part.id, accumulatedContent: '' });
}
} break;
case 'tool-result': {
let outputType: 'text' | 'json' = 'text';
let outputValue: string = '';
switch (typeof token.output) {
case 'string':
outputType = 'text';
outputValue = token.output;
break;
case 'object':
outputType = 'json';
outputValue = JSON.stringify(token.output);
break;
default:
console.error('Unknown output type', token.output);
await httpClient.update('tool_calls', token.toolCallId, {
status: 'failed',
error: {
type: 'text',
value: 'Tool returned invalid output',
},
});
activeToolCalls.delete(token.toolCallId);
break;
}
await httpClient.update('tool_calls', token.toolCallId, {
status: 'completed',
output: {
type: outputType,
value: outputValue,
},
});
activeToolCalls.delete(token.toolCallId);
} break;
case 'tool-error': {
console.error('Tool error:', token);
let outputType: 'text' | 'json';
let outputValue: string;
switch (typeof token.error) {
case 'string':
outputType = 'text';
outputValue = token.error;
break;
case 'object':
outputType = 'json';
outputValue = JSON.stringify(token.error);
break;
default:
console.error('Unknown error type', token.error);
outputType = 'text';
outputValue = 'Tool returned invalid output';
break;
}
if (activeToolCalls.has(token.toolCallId)) {
await httpClient.update('tool_calls', token.toolCallId, {
status: 'failed',
error: {
type: outputType,
value: outputValue,
},
});
} else {
key = `tool-call-${curStepIdx}`;
const toolCallId = token.toolCallId;
part = await httpClient.insert('message_parts', {
topicId,
messageId: message.id,
userId,
toolCallId: token.toolCallId,
type: 'tool-call',
content: '',
finished: false,
createdAt: new Date(),
lastUpdatedAt: new Date(),
});
await httpClient.insert('tool_calls', {
id: toolCallId,
userId: userId,
toolName: token.toolName,
status: 'failed',
input: null,
output: null,
error: {
type: outputType,
value: outputValue,
},
createdAt: new Date(),
});
activeParts.set(key, { id: part.id, accumulatedContent: '' });
}
activeToolCalls.delete(token.toolCallId);
} break;
case 'error': {
console.error('Error:', token);
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
let error = INTERNAL_ERROR;
if (typeof token.error === 'string') {
error = token.error;
} else if (typeof token.error === 'object') {
error = JSON.stringify(token.error);
}
await httpClient.update('generations', generationId, {
status: 'failed',
error,
});
} break;
case 'finish': {
let tps;
if (ttft !== undefined && token.totalUsage.outputTokens !== undefined) {
const tokenStreamStart = requestStart! + ttft;
// this is the *real* request duration, excluding the
// TTFT
const requestDuration = performance.now() - tokenStreamStart;
tps = token.totalUsage.outputTokens / (requestDuration / 1000);
}
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
switch (token.finishReason) {
case 'error':
await httpClient.update('generations', generationId, {
status: 'failed',
error: INTERNAL_ERROR,
});
break;
case 'content-filter':
await httpClient.update('generations', generationId, {
status: 'failed',
error: 'Content was filtered',
});
break;
}
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId));
if (generation === null || generation.status === 'failed') return;
await httpClient.update('generations', generationId, {
status: 'completed',
tokens: {
input: token.totalUsage.inputTokens,
cache: {
read: token.totalUsage.inputTokenDetails.cacheReadTokens,
write: token.totalUsage.inputTokenDetails.cacheWriteTokens,
},
output: token.totalUsage.outputTokens,
thinking: token.totalUsage.outputTokenDetails.reasoningTokens,
ttft,
tps,
},
});
// I hate you switch fallthroughs
} break;
case 'abort': {
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
for (const toolCallId of activeToolCalls.keys()) {
await httpClient.update('tool_calls', toolCallId, {
status: 'cancelled',
});
}
await httpClient.update('generations', generationId, {
status: 'cancelled',
});
} break;
case 'file':
todo('file token type', token);
break;
case 'raw':
todo('raw token type', token);
break;
case 'source':
todo('source token type', token);
break;
case 'tool-approval-request':
todo('tool-approval-request token type', token);
break;
// typescript thinks this is not a real token type?
// case 'tool-output-denied':
// todo('tool-output-denied token type', token);
// break;
case 'finish-step':
case 'tool-input-delta':
case 'tool-input-end':
// handled or irrelevant
break;
}
}
} catch (error: any) {
console.error(error);
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
for (const toolCallId of activeToolCalls.keys()) {
await httpClient.update('tool_calls', toolCallId, {
status: 'failed',
error: {
type: 'text',
value: 'An unknown error occurred',
},
});
activeToolCalls.delete(toolCallId);
}
await httpClient.update('generations', generationId, {
status: 'failed',
error: error.message,
});
} finally {
completeGeneration(generationId);
if (logFile !== undefined) logFile.close();
}
}
+47
View File
@@ -0,0 +1,47 @@
import { userEvents } from "~~/server/utils/events";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user.id;
let controller: ReadableStreamDefaultController;
let pingInterval: NodeJS.Timeout;
const stream = new ReadableStream({
start(c) {
controller = c;
// Send initial connection message
try {
controller.enqueue(':connected\n\n');
} catch (e) {
console.error('Failed to send initial connection:', e);
return;
}
userEvents.subscribe(userId, controller);
pingInterval = setInterval(() => {
try {
controller.enqueue(':heartbeat\n\n');
} catch (e) {
clearInterval(pingInterval);
}
}, 15000);
},
cancel() {
console.log(`User ${userId} event stream cancelled`);
clearInterval(pingInterval);
userEvents.unsubscribe(userId, controller!);
}
});
setHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
return sendStream(event, stream);
});
+23
View File
@@ -0,0 +1,23 @@
import { and, eq } from "drizzle-orm";
import { files } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const id = getRouterParam(event, 'id')!;
const file = await db.delete(files).where(and(eq(files.id, id), eq(files.userId, userId))).returning();
if (!file) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'File not found',
});
}
return { ok: true };
});
+47
View File
@@ -0,0 +1,47 @@
import { files } 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 result = await readValidatedBody(event, (body) =>
z
.object({
id: z.string(),
name: z.string(),
mimeType: z.string(),
url: z.string(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const { id, name, mimeType, url } = result.data;
const file = await db.insert(files).values({
id,
userId,
name,
mimeType,
url,
createdAt: new Date(),
}).returning();
if (!file) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to insert file',
message: 'Failed to insert file',
});
}
return { ok: true };
});
@@ -0,0 +1,146 @@
import { sql } from "drizzle-orm";
import { messages } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
import { topicEvents } from "~~/server/utils/events";
async function deeplyDeleteMessage(messageId: string, topicId: string, userId: string) {
const children = await db.query.messages.findMany({
where: {
parentMessageId: messageId,
userId,
}
} as any);
for (const child of children) {
await deeplyDeleteMessage(child.id, topicId, userId);
}
await db.delete(messages).where(sql`${messages.id} = ${messageId} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_DELETED',
payload: { id: messageId },
});
}
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const messageId = getRouterParam(event, 'messageId')!;
const message = await db.query.messages.findFirst({
where: {
id: messageId,
userId,
}
} as any);
if (!message) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid message',
});
}
const topicId = message.topicId;
const allTopicMessages = await db.query.messages.findMany({
where: {
topicId,
userId,
},
orderBy: {
createdAt: 'asc',
},
});
const messagesMap = new Map(allTopicMessages.map(m => [m.id, { ...m, children: [] as typeof m[] }]));
for (const msg of messagesMap.values()) {
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
messagesMap.get(msg.parentMessageId)!.children.push(msg);
}
}
const rootMessage = messagesMap.get(messageId);
if (!rootMessage) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid message',
});
}
if (rootMessage.role === 'user') {
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
return { ok: true };
}
if (rootMessage.deleted === true) {
const childMessage = rootMessage.children[rootMessage.focusedIndex!];
if (!childMessage) {
console.error('Message not found');
return { ok: false };
}
await deeplyDeleteMessage(childMessage.id, topicId, userId);
const remainingChildren = rootMessage.children.filter(child => child!.id !== childMessage.id);
if (remainingChildren.length === 0) {
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
} else {
const newFocusedIndex = rootMessage.focusedIndex && rootMessage.focusedIndex > 0
? Math.min(rootMessage.focusedIndex - 1, remainingChildren.length - 1)
: 0;
await db.update(messages).set({ focusedIndex: newFocusedIndex }).where(sql`${messages.id} = ${rootMessage.id} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_UPDATED',
payload: { ...rootMessage, focusedIndex: newFocusedIndex },
});
}
return { ok: true };
}
if (
(rootMessage.focusedIndex !== undefined && rootMessage.focusedIndex !== null)
&& rootMessage.focusedIndex > 0
&& rootMessage.children.length > 0
) {
const childMessage = rootMessage.children[rootMessage.focusedIndex - 1];
if (!childMessage) {
console.error('Message not found');
return { ok: false };
}
await deeplyDeleteMessage(childMessage.id, topicId, userId);
const remainingChildren = rootMessage.children.filter(child => child!.id !== childMessage.id);
const newFocusedIndex = remainingChildren.length > 0
? Math.min(rootMessage.focusedIndex - 1, remainingChildren.length - 1)
: 0;
await db.update(messages).set({ focusedIndex: newFocusedIndex }).where(sql`${messages.id} = ${rootMessage.id} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_UPDATED',
payload: { ...rootMessage, focusedIndex: newFocusedIndex },
});
return { ok: true };
}
if (rootMessage.children.length === 0) {
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
return { ok: true };
}
await db.update(messages).set({ deleted: true }).where(sql`${messages.id} = ${rootMessage.id} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_UPDATED',
payload: { ...rootMessage, deleted: true },
});
return { ok: true };
});
@@ -0,0 +1,30 @@
import { db } from "~~/server/lib/db";
import { messages } from "~~/drizzle/schema";
import { createUpdateSchema } from "drizzle-orm/zod";
import { and, eq } from "drizzle-orm";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const messageId = getRouterParam(event, 'messageId');
const result = await readValidatedBody(event, createUpdateSchema(messages).safeParse);
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'Bad Request',
message: result.error.issues.map(issue => issue.message).join(', '),
});
}
const res = await db.update(messages).set(result.data).where(and(eq(messages.id, messageId!), eq(messages.userId, event.context.user!.id)));
if (res.rowCount === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'Message not found',
});
}
return { ok: true };
});
@@ -0,0 +1,26 @@
import { and, eq } from "drizzle-orm";
import { models } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const modelId = getRouterParam(event, 'modelId')!;
const res = await db.delete(models).where(
and(
eq(models.id, modelId),
eq(models.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid model',
});
}
return { ok: true };
});
+95
View File
@@ -0,0 +1,95 @@
import { and, eq } from "drizzle-orm";
import { models } 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 result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().optional(),
cost: z.object({
prompt: z.string().optional(),
completion: z.string().optional(),
request: z.string().optional(),
image: z.string().optional(),
imageTokens: z.string().optional(),
imageOutput: z.string().optional(),
audio: z.string().optional(),
audioOutput: z.string().optional(),
inputAudioCache: z.string().optional(),
webSearch: z.string().optional(),
internalReasoning: z.string().optional(),
inputCacheRead: z.string().optional(),
inputCacheWrite: z.string().optional(),
discount: z.string().optional(),
}).optional(),
inputModalities: z.array(z.string()).optional(),
outputModalities: z.array(z.string()).optional(),
capabilities: z.array(z.string()).optional(),
contextWindow: z.number().optional(),
supportedParameters: z.array(z.string()).optional(),
isCustom: z.boolean().optional(),
enabled: z.boolean().optional(),
releasedAt: z.string().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const modelId = getRouterParam(event, 'modelId')!;
const { name, cost, inputModalities, outputModalities, capabilities, contextWindow, supportedParameters, isCustom, enabled, releasedAt } = result.data;
const existing = await db.query.models.findFirst({
where: {
id: modelId,
userId,
},
});
if (!existing) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid model',
});
}
const res = await db.update(models)
.set({
name,
cost,
inputModalities,
outputModalities,
capabilities,
contextWindow,
supportedParameters,
isCustom,
enabled,
releasedAt: releasedAt ? new Date(releasedAt) : existing.releasedAt ?? null,
})
.where(
and(
eq(models.id, modelId),
eq(models.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid model',
});
}
return { ok: true };
});
+86
View File
@@ -0,0 +1,86 @@
import * as z from 'zod';
import { models } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const result = await readValidatedBody(event, (body) =>
z.object({
id: z.string(),
providerId: z.string(),
externalId: z.string(),
name: z.string(),
cost: z.object({
prompt: z.string().optional(),
completion: z.string().optional(),
request: z.string().optional(),
image: z.string().optional(),
imageTokens: z.string().optional(),
imageOutput: z.string().optional(),
audio: z.string().optional(),
audioOutput: z.string().optional(),
inputAudioCache: z.string().optional(),
webSearch: z.string().optional(),
internalReasoning: z.string().optional(),
inputCacheRead: z.string().optional(),
inputCacheWrite: z.string().optional(),
discount: z.string().optional(),
}).optional(),
inputModalities: z.array(z.string()).optional(),
outputModalities: z.array(z.string()).optional(),
capabilities: z.array(z.string()).optional(),
contextWindow: z.number().nullable().optional(),
supportedParameters: z.array(z.string()).optional(),
isCustom: z.boolean().optional(),
enabled: z.boolean().optional(),
releasedAt: z.date().nullable().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
...result.error
});
}
const provider = await db.query.providers.findFirst({
where: {
id: result.data.providerId,
userId: event.context.user!.id,
},
with: {
models: true,
}
});
if (!provider) {
throw createError({
statusCode: 404,
message: 'Provider not found',
});
}
const model = await db.insert(models).values({
id: result.data.id,
userId: event.context.user!.id,
providerId: result.data.providerId,
externalId: result.data.externalId,
name: result.data.name,
cost: result.data.cost,
inputModalities: result.data.inputModalities,
outputModalities: result.data.outputModalities,
capabilities: result.data.capabilities,
contextWindow: result.data.contextWindow,
supportedParameters: result.data.supportedParameters,
isCustom: result.data.isCustom,
enabled: result.data.enabled,
releasedAt: result.data.releasedAt,
}).onConflictDoNothing().returning();
return {
model,
};
});
@@ -0,0 +1,68 @@
import { and, eq } from "drizzle-orm";
import { providers } 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 result = await readValidatedBody(event, (body) =>
z
.object({
config: z.object({
apiKey: z.string().optional(),
apiProxyUrl: z.string().optional(),
}).optional(),
enabled: z.boolean().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const providerId = getRouterParam(event, 'providerId')!;
const { config, enabled } = result.data;
const existing = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
});
if (!existing) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
});
}
const res = await db.update(providers)
.set({
config,
enabled,
})
.where(
and(
eq(providers.id, providerId),
eq(providers.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
});
}
return { ok: true };
});
@@ -0,0 +1,28 @@
// delete all models for a provider
import { and, eq } from "drizzle-orm";
import { models } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const providerId = getRouterParam(event, 'providerId');
if (!providerId) throw createError({ statusCode: 400, message: 'Invalid provider' });
const provider = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
});
if (!provider) throw createError({ statusCode: 404, message: 'Provider not found' });
await db.delete(models).where(and(eq(models.providerId, providerId), eq(models.userId, userId)));
return {
ok: true,
};
});
@@ -0,0 +1,38 @@
import { and, eq } from 'drizzle-orm';
import * as z from 'zod';
import { models } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const provider = await db.query.providers.findFirst({
where: {
id: getRouterParam(event, 'providerId'),
userId,
},
});
if (!provider) throw createError({ statusCode: 404, message: 'Provider not found' });
const result = await readValidatedBody(event, (body) =>
z.object({ enabled: z.boolean() }).safeParse(body)
);
if (!result.success) {
throw createError({ statusCode: 400, message: result.error.issues[0]!.message });
}
const { enabled } = result.data;
const res = await db.update(models).set({
enabled
}).where(and(
eq(models.userId, userId),
eq(models.providerId, provider.id),
));
return {
ok: res.rowCount ?? 0 > 0,
};
});
+202 -70
View File
@@ -1,48 +1,44 @@
import { type Entity } from '@triplit/client';
import * as z from 'zod';
import { SupportedModalities } from '~/types/model';
import { Providers } from '~/types/model';
import { httpClient } from '~~/server/lib/triplit';
import { GatewayFetchError, getProviderDetails } from '~~/server/utils/ai-provider';
import { getModelsDevData } from '~~/server/utils/models-dev';
import { schema } from '~~/triplit/schema';
import { Err, Ok, type Result } from '~~/types/result';
import { db } from '~~/server/lib/db';
import { type Provider } from '~/composables/useModels';
import { models } from '~~/drizzle/schema';
import { and, eq, notInArray } from 'drizzle-orm';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const userId = event.context.user!.id as string;
const result = await readValidatedBody(event, (body) =>
z
.object({
providerApiKey: z.string().optional(),
})
.safeParse(body),
z.object({ providerApiKey: z.string().optional() }).safeParse(body)
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
throw createError({ statusCode: 400, message: result.error.issues[0]!.message });
}
const providerId = getRouterParam(event, 'providerId')
const providerId = getRouterParam(event, 'providerId');
if (!providerId) throw createError({ statusCode: 400, message: 'Invalid provider' });
const provider = await httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId));
if (provider === null || provider.userId !== userId) {
throw createError({
statusCode: 400,
message: 'Invalid provider',
});
}
const provider = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
with: { models: true }
});
console.log("apiKey", result.data.providerApiKey);
if (!provider) throw createError({ statusCode: 404, message: 'Provider not found' });
const [providerModelsRes, modelsDevRes] = await Promise.all([
fetchProviderModels(provider, result.data.providerApiKey),
getModelsDevData(),
]);
if (!providerModelsRes.ok) {
switch (providerModelsRes.error) {
case ProviderFetchError.NoProviderApiKey: {
@@ -60,17 +56,75 @@ export default defineEventHandler(async (event) => {
}
}
console.log("providerModelsRes.data", providerModelsRes.data);
const normalizedModels = await normalizeResponse(
providerModelsRes.data.data,
provider.type,
provider.type as typeof Providers[number],
providerModelsRes.data.baseURL,
modelsDevRes
);
const existingModels = provider.models || [];
const apiModelExternalIds = normalizedModels.map((m: any) => m.id);
console.log({ existingModels, apiModelExternalIds, normalizedModels });
await db.transaction(async (tx) => {
if (existingModels.length > 0) {
await tx.delete(models)
.where(
and(
eq(models.providerId, providerId),
eq(models.isCustom, false),
notInArray(models.externalId, apiModelExternalIds)
)
);
}
for (const model of normalizedModels) {
const existing = existingModels.find(m => m.externalId === model.id);
if (existing) {
await tx.update(models)
.set({
name: model.name || existing.name,
cost: model.cost,
inputModalities: model.attributes.inputModalities,
outputModalities: model.attributes.outputModalities,
capabilities: model.attributes.capabilities,
contextWindow: model.attributes.contextWindow,
releasedAt: model.releasedAt ? new Date(model.releasedAt) : existing.releasedAt,
})
.where(eq(models.id, existing.id));
} else {
await tx.insert(models).values({
userId,
providerId,
externalId: model.id,
name: model.name || model.id,
cost: model.cost || {},
inputModalities: model.attributes.inputModalities,
outputModalities: model.attributes.outputModalities,
capabilities: model.attributes.capabilities,
contextWindow: model.attributes.contextWindow,
isCustom: false,
enabled: false,
releasedAt: model.releasedAt ? new Date(model.releasedAt) : null,
});
}
}
});
const updatedModels = await db.query.models.findMany({
where: {
providerId,
},
orderBy: {
releasedAt: 'desc'
}
});
return {
models: normalizedModels,
models: updatedModels
};
});
@@ -79,22 +133,14 @@ enum ProviderFetchError {
NoProviderBaseUrl,
}
const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => {
const fetchProviderModels = async (provider: Provider, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => {
const providerDetails = await getProviderDetails(provider, providerApiKey);
if (!providerDetails.ok) {
switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: {
// throw createError({
// statusCode: 400,
// message: `${provider.type} provider requires an API key`,
// });
return Err(ProviderFetchError.NoProviderApiKey);
}
case GatewayFetchError.NoProviderBaseUrl: {
// throw createError({
// statusCode: 400,
// message: 'Invalid provider URL',
// });
return Err(ProviderFetchError.NoProviderBaseUrl);
}
}
@@ -107,7 +153,7 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
// longcat doesn't have a model list endpoint so we just hardcode them here,
// sry. I talked to Meituan and this is what they said:
// 后续如果我们新增了这样的接口会及时同步您。
// en (approx): If we add an interface like this in the future, we will promptly update you
// en (approx): If we add an interface like this in the future, we will keep you in sync
return Ok({
data: {
models: [
@@ -122,8 +168,8 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
}
},
{
id: "LongCat-Flash-Thinking",
name: "LongCat Flash Thinking",
id: "LongCat-Flash-Thinking-2601",
name: "LongCat Flash Thinking (2601)",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
@@ -132,12 +178,12 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
}
},
{
id: "LongCat-Flash-Thinking-2601",
name: "LongCat Flash Thinking (2601)",
id: "LongCat-Flash-Omni-2603",
name: "LongCat Flash Omni",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['reasoning', 'tools'],
inputModalities: ['text', 'image', 'audio', 'video'],
outputModalities: ['text', 'audio'],
capabilities: ['tools'],
contextWindow: 256_000,
}
},
@@ -155,6 +201,24 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
},
baseURL
});
case 'inception':
return Ok({
data: {
models: [
{
id: "mercury-2",
name: "Mercury 2",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['tools', 'reasoning'],
contextWindow: 128_000,
}
},
],
},
baseURL
});
}
let res;
@@ -189,7 +253,6 @@ function mergeSets(setA: Set<string>, setB: Set<string>): string[] {
const getModelData = (modelId: string, providerId: string, modelsDevData: any) => {
const modelData = modelsDevData[providerId]?.models[modelId];
console.log("modelData", modelData);
if (modelData === undefined) return {
cost: {},
@@ -200,13 +263,13 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
}
};
const capabilities = new Set<string>();
const capabilities = new Array<string>();
if (modelData.reasoning) {
capabilities.add('reasoning');
capabilities.push('reasoning');
}
if (modelData.tool_call) {
capabilities.add('tools');
capabilities.push('tools');
}
let inputModalities = modelData.modalities.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
@@ -236,7 +299,7 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
attributes: {
inputModalities,
outputModalities,
capabilities: modelData.capabilities || [],
capabilities: capabilities || [],
contextWindow,
supported_parameters: modelData.supportedParameters,
},
@@ -245,15 +308,6 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
}
}
// const formatBig = (bigValue: Big) => {
// let str = bigValue.toString();
// if (!str.includes('.')) return str + '.00';
// if (str.split('.')[1]!.length === 1) return str + '0';
// return str;
// };
// this function multiplies a string in the format of 'D.DD' by 1_000_000
// it does this by finding the first digit that is not a zero, and then
// left shifting it in decimal by 3 places
@@ -320,7 +374,7 @@ const formatMoney = (value: string) => {
return `${integerPart}.${fractionalPart}`;
}
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any) => {
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any): Promise<any[]> => {
switch (provider) {
case 'cerebras': {
console.log("response.data", response.data);
@@ -397,14 +451,6 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
name: model.name as string,
cost: pricing,
attributes: {
// inputModalities: mergeSets(
// new Set(modelData.inputModalities || []),
// new Set(model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
// ),
// outputModalities: mergeSets(
// new Set(modelData.outputModalities || []),
// new Set(model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
// ),
inputModalities: model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
capabilities: Array.from(capabilities),
@@ -417,6 +463,33 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return models;
}
case 'closedrouter': {
return response.data.map((model: any) => {
const capabilities = new Array<string>();
if (model.capabilities) {
if (model.capabilities.reasoning) {
capabilities.push('reasoning');
}
if (model.capabilities.tool_call) {
capabilities.push('tools');
}
}
return {
id: model.id,
name: model.name,
attributes: {
inputModalities: model.modalities?.input || ['text'],
outputModalities: model.modalities?.output || ['text'],
capabilities: capabilities,
contextWindow: model.context_window,
supported_parameters: [],
},
}
});
}
case 'ollama': {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -503,12 +576,21 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return Array.from(models.values());
}
case 'vllm': {
return response.data.map((model: any) => {
return {
id: model.id,
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
contextWindow: model.max_model_len,
}
}
})
}
case 'google': {
return response.models.map((model: any) => ({ ...getModelData(model.name.replace('models/', ''), provider, modelsDevData), id: model.name.replace('models/', ''), name: model.displayName }));
}
case 'longcat': {
return response.models;
}
case 'cohere': {
const models = [];
for (const model of response.models) {
@@ -551,5 +633,55 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return models;
}
case 'mistral': {
const models = [];
for (const model of response.data) {
const inputModalities = new Set<string>();
const capabilities = new Set<string>();
for (const capability of Object.keys(model.capabilities)) {
if (model.capabilities[capability] === false) continue;
switch (capability) {
case 'function_calling': {
capabilities.add('tools');
} break;
case 'completion_chat': {
inputModalities.add('text');
capabilities.add('completion');
} break;
case 'vision': {
inputModalities.add('image');
} break;
}
}
const modelData = getModelData(model.id, provider, modelsDevData);
models.push({
...modelData,
id: model.id,
name: modelData.name || model.name || model.id,
attributes: {
...modelData.attributes,
inputModalities: mergeSets(
inputModalities,
new Set(modelData.attributes?.inputModalities || [])
),
capabilities: mergeSets(
capabilities,
new Set(modelData.attributes?.capabilities || [])
),
contextWindow: model.max_context_length || modelData.attributes?.contextWindow,
},
});
}
return models;
}
case 'inception':
case 'longcat': {
return response.models;
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { nanoid } from "nanoid";
import { Providers } from "~/types/model";
import { db } from "~~/server/lib/db";
import { providers as providersSchema } from "~~/drizzle/schema";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const providers = await db.query.providers.findMany({
where: {
userId,
},
with: {
models: true,
},
});
Providers.forEach(async p => {
if (!providers.find(provider => provider.type === p)) {
const [created] = await db.insert(providersSchema).values({
id: nanoid(),
userId,
type: p,
name: p,
enabled: false,
config: {},
createdAt: new Date(),
updatedAt: new Date(),
}).returning() as typeof providers;
if (created) {
created.models = [];
providers.push(created);
}
}
});
return providers;
})
+34
View File
@@ -0,0 +1,34 @@
import { and, eq } from 'drizzle-orm';
import { settings } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
const defaultAppearance = {
colorScheme: 'system' as const,
accent: 'violet',
neutral: 'zinc',
hinting: 0,
fontSize: 'md',
};
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const existing = await db.query.settings.findFirst({
where: { userId },
});
if (existing) {
return existing;
}
const [created] = await db.insert(settings)
.values({
userId,
systemAssistants: {},
appearance: defaultAppearance,
})
.returning();
return created;
});
+87
View File
@@ -0,0 +1,87 @@
import { and, eq } from 'drizzle-orm';
import * as z from 'zod';
import { settings } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
const appearanceSchema = z.object({
colorScheme: z.enum(['light', 'dark', 'system']).optional(),
accent: z.string().optional(),
neutral: z.string().optional(),
hinting: z.number().min(0).max(100).optional(),
fontSize: z.string().optional(),
});
const updateSettingsSchema = z.object({
systemAssistants: z.any().optional(),
appearance: appearanceSchema.optional(),
});
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const body = await readBody(event);
const parseResult = updateSettingsSchema.safeParse(body);
if (!parseResult.success) {
throw createError({
statusCode: 400,
message: parseResult.error.issues[0]!.message,
});
}
const updates = parseResult.data;
const existing = await db.query.settings.findFirst({
where: { userId },
});
if (!existing) {
throw createError({
statusCode: 404,
statusMessage: 'Settings not found',
});
}
const existingId: string = existing.id;
const mergeAppearance = (
existingAppearance: Record<string, unknown> | null,
incoming: typeof updates.appearance,
): Record<string, unknown> => {
const base = existingAppearance || {};
if (!incoming) return base;
return { ...base, ...incoming };
};
const mergeSystemAssistants = (
existingSA: Record<string, unknown> | null,
incoming: typeof updates.systemAssistants,
): Record<string, unknown> => {
const base = existingSA || {};
if (!incoming) return base;
return { ...base, ...incoming };
};
const newAppearance = mergeAppearance(
existing.appearance as Record<string, unknown> | null,
updates.appearance,
);
const newSystemAssistants = mergeSystemAssistants(
existing.systemAssistants as Record<string, unknown> | null,
updates.systemAssistants,
);
await db.update(settings)
.set({
appearance: newAppearance,
systemAssistants: newSystemAssistants,
})
.where(and(eq(settings.id, existingId)));
const updated = await db.select()
.from(settings)
.where(and(eq(settings.id, existingId)))
.limit(1);
return updated[0];
});
@@ -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 };
});
+95
View File
@@ -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);
});
+65
View File
@@ -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
};
})
+45
View File
@@ -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 };
});
+36
View File
@@ -0,0 +1,36 @@
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 as string;
const result = await getValidatedQuery(event, z.object({
page: z.number().optional(),
limit: z.number().optional(),
}).safeParse)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const { page, limit } = result.data;
const topics = await db.query.topics.findMany({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
limit: page && limit ? limit : undefined,
offset: page && limit ? (page - 1) * limit : undefined,
});
return {
topics,
};
})