feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
})
|
||||
@@ -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;
|
||||
})
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
Reference in New Issue
Block a user