streaming, markdown, model selecting, and lots more
This commit is contained in:
@@ -1,29 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents } from "~~/db/schema";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
const { id } = event.context.params!;
|
||||
|
||||
const [row] = await db.select().from(agents).where(eq(agents.id, id));
|
||||
if (row === undefined || row.userId !== event.context.user.id) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Agent not found' });
|
||||
}
|
||||
|
||||
const { name, systemPrompt, imageUrl } = await readBody(event);
|
||||
const updateObject: Partial<typeof row> = {};
|
||||
if (name !== undefined) updateObject.name = name;
|
||||
if (systemPrompt !== undefined) updateObject.systemPrompt = systemPrompt;
|
||||
if (imageUrl !== undefined) updateObject.imageUrl = imageUrl;
|
||||
|
||||
if (Object.keys(updateObject).length === 0) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'No update data provided' });
|
||||
}
|
||||
|
||||
const [agent] = await db.update(agents).set(updateObject).where(eq(agents.id, id)).returning();
|
||||
|
||||
return agent;
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { agents } from "~~/db/schema";
|
||||
import { protectRoute } from "~~/server/utils/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
const userId = event.context.user.id;
|
||||
|
||||
// Only return agents for the authenticated user
|
||||
const rows = await db.select().from(agents).where(eq(agents.userId, userId));
|
||||
return rows;
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { agents } from "~~/db/schema";
|
||||
import { protectRoute } from "~~/server/utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
const { name, systemPrompt, imageUrl } = await readBody(event);
|
||||
const userId = event.context.user.id;
|
||||
if (!name || !systemPrompt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const [inserted] = await db.insert(agents).values({ name, userId, systemPrompt, imageUrl }).returning();
|
||||
return inserted;
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { auth } from "~~/lib/auth";
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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!);
|
||||
|
||||
if (!success) {
|
||||
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
|
||||
if (generation !== null && generation.status === 'pending') {
|
||||
await httpClient.update('generations', generationId!, {
|
||||
status: 'cancelled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Generation not found or already completed',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
@@ -1,40 +1,658 @@
|
||||
import { protectRoute } from '~~/server/utils/auth';
|
||||
import { createPendingGeneration } from '~~/server/utils/generation';
|
||||
import type { GenerateRequestBody } from '~~/server/types/chat';
|
||||
import { createOpenRouter, type OpenRouterProvider } from '@openrouter/ai-sdk-provider';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import { type ModelMessage, modelMessageSchema, streamText, 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';
|
||||
|
||||
export const messagesSchema = z.array(modelMessageSchema);
|
||||
|
||||
// quick access
|
||||
// const ACTIVE_MODEL_ID = 'openrouter/free';
|
||||
// const ACTIVE_MODEL_ID = 'x-ai/grok-4.1-fast';
|
||||
const ACTIVE_MODEL_ID = 'google/gemini-3-flash-preview';
|
||||
// const ACTIVE_MODEL_ID = 'arcee-ai/trinity-mini:free';
|
||||
|
||||
type ModelGateway = OpenRouterProvider;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const body = await readBody(event) as GenerateRequestBody;
|
||||
const { topicId, messages, regeneratesFrom } = body;
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
messages: messagesSchema.min(1),
|
||||
topicId: z.string(),
|
||||
model: z.object({
|
||||
providerId: z.string(),
|
||||
modelId: z.string(),
|
||||
args: z.any(),
|
||||
}),
|
||||
providerApiKey: z.string().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!topicId || !messages) {
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing required fields: topicId and messages'
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const { messages, topicId, model: { modelId, providerId }, providerApiKey } = result.data;
|
||||
|
||||
const provider = await httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId));
|
||||
if (provider === null || provider.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Messages array cannot be empty'
|
||||
message: 'Invalid provider',
|
||||
});
|
||||
}
|
||||
|
||||
const model = await httpClient.fetchOne(httpClient.query('models').Where('id', '=', modelId));
|
||||
if (model === null || model.providerId !== model.providerId || model.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid model',
|
||||
});
|
||||
}
|
||||
|
||||
const existingPendingGenerations = await httpClient.fetchOne(
|
||||
httpClient.query('generations').Where('topicId', '=', topicId).Where('status', '=', 'pending'),
|
||||
);
|
||||
if (existingPendingGenerations !== null) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'There cannot be more than one active generation per topic',
|
||||
});
|
||||
}
|
||||
|
||||
let gateway: ModelGateway;
|
||||
|
||||
switch (provider.type) {
|
||||
case 'openrouter': {
|
||||
if (providerApiKey === undefined) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'OpenRouter provider requires an API key',
|
||||
});
|
||||
}
|
||||
|
||||
gateway = createOpenRouter({
|
||||
apiKey: providerApiKey,
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://localhost:3000',
|
||||
'X-Title': 'Veridian',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown provider type: ${provider.type}`);
|
||||
}
|
||||
|
||||
const generationId = nanoid();
|
||||
const message = await httpClient.insert('messages', {
|
||||
topicId,
|
||||
generationId,
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
});
|
||||
await httpClient.insert('generations', {
|
||||
id: generationId,
|
||||
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) {
|
||||
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
|
||||
logMessage = (message: string) => {
|
||||
logFile!.write(message + '\n');
|
||||
};
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
generateResponse(message, { gateway, model: model.externalId }, generationId, userId, messages, 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');
|
||||
};
|
||||
|
||||
async function generateResponse(
|
||||
message: Entity<typeof schema, 'messages'>,
|
||||
model: {
|
||||
gateway: ModelGateway,
|
||||
model: string,
|
||||
},
|
||||
generationId: string,
|
||||
userId: string,
|
||||
messages: ModelMessage[],
|
||||
log?: (message: string) => void,
|
||||
logFile?: fs.FileHandle,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
addPendingGeneration(generationId, controller);
|
||||
|
||||
const activeParts = new Map<string, { id: string; accumulatedContent: string; providerOptions?: any }>();
|
||||
const activeToolCalls = new Map<string, { id: string }>();
|
||||
|
||||
const response = streamText({
|
||||
model: model.gateway(model.model),
|
||||
messages,
|
||||
// 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: {
|
||||
// // 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: tool({
|
||||
// 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,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// glob: tool({
|
||||
// inputSchema: z.object({
|
||||
// pattern: z.string(),
|
||||
// }),
|
||||
// outputSchema: z.object({
|
||||
// files: z.array(z.object({ name: z.string(), type: z.string() })),
|
||||
// }),
|
||||
// execute: async ({ pattern }) => {
|
||||
// const rawFiles = await glob(pattern, { withFileTypes: true });
|
||||
// const files = rawFiles.map((file) => ({
|
||||
// name: file.name,
|
||||
// type: file.isFile() ? 'file' : 'directory',
|
||||
// }));
|
||||
|
||||
// return {
|
||||
// files,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// readFile: tool({
|
||||
// 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(),
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// readFiles: tool({
|
||||
// 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,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// fetchUrl: tool({
|
||||
// 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,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// },
|
||||
onStepFinish: async (result) => {
|
||||
if (result.toolResults.length > 0) {
|
||||
for (const toolResult of result.toolResults) {
|
||||
let outputType: 'text' | 'json' = 'text';
|
||||
let outputValue: string = '';
|
||||
|
||||
switch (typeof toolResult.output) {
|
||||
case 'string':
|
||||
outputType = 'text';
|
||||
outputValue = toolResult.output;
|
||||
break;
|
||||
case 'object':
|
||||
outputType = 'json';
|
||||
outputValue = JSON.stringify(toolResult.output, null, 2);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown output type', toolResult.output);
|
||||
await httpClient.update('tool_calls', toolResult.toolCallId, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: 'text',
|
||||
value: 'Tool returned invalid output',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
await httpClient.update('tool_calls', toolResult.toolCallId, {
|
||||
status: 'completed',
|
||||
output: {
|
||||
type: outputType,
|
||||
value: outputValue,
|
||||
},
|
||||
});
|
||||
|
||||
activeToolCalls.delete(toolResult.toolCallId);
|
||||
}
|
||||
}
|
||||
},
|
||||
onFinish: async (result) => {
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'completed',
|
||||
tokens: {
|
||||
input: result.totalUsage.inputTokens,
|
||||
cache: {
|
||||
read: result.totalUsage.inputTokenDetails.cacheReadTokens,
|
||||
write: result.totalUsage.inputTokenDetails.cacheWriteTokens,
|
||||
},
|
||||
output: result.totalUsage.outputTokens,
|
||||
thinking: result.totalUsage.outputTokenDetails.reasoningTokens,
|
||||
},
|
||||
});
|
||||
},
|
||||
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 activeToolCall of activeToolCalls.values()) {
|
||||
await httpClient.update('tool_calls', activeToolCall.id, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: 'text',
|
||||
value: 'An unknown error occurred',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
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 {
|
||||
const generationId = await createPendingGeneration(event.context.user.id, topicId, messages, regeneratesFrom);
|
||||
for await (const token of response.fullStream) {
|
||||
log?.(JSON.stringify(token, null, 2));
|
||||
|
||||
return {
|
||||
generationId,
|
||||
status: 'pending',
|
||||
regeneratesFrom
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create generation:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create generation'
|
||||
switch (token.type) {
|
||||
case 'start-step': {
|
||||
curStepIdx++;
|
||||
} break;
|
||||
case 'tool-input-start': {
|
||||
key = `tool-call-${curStepIdx}`;
|
||||
|
||||
const toolCallId = token.id;
|
||||
|
||||
part = await httpClient.insert('message_parts', {
|
||||
messageId: message.id,
|
||||
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(key, { id: 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', {
|
||||
messageId: message.id,
|
||||
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': {
|
||||
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, null, 2);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown input type', token.input);
|
||||
break;
|
||||
}
|
||||
|
||||
await httpClient.update('tool_calls', token.toolCallId, {
|
||||
status: 'pending',
|
||||
input: {
|
||||
type: inputType,
|
||||
value: inputValue,
|
||||
},
|
||||
});
|
||||
} break;
|
||||
case 'tool-error': {
|
||||
const toolCall = activeToolCalls.get(token.toolCallId);
|
||||
if (toolCall === undefined) {
|
||||
console.error('Received tool-error without a start');
|
||||
break;
|
||||
}
|
||||
|
||||
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, null, 2);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown error type', token.error);
|
||||
outputType = 'text';
|
||||
outputValue = 'Tool returned invalid output';
|
||||
break;
|
||||
}
|
||||
|
||||
await httpClient.update('tool_calls', toolCall.id, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: outputType,
|
||||
value: outputValue,
|
||||
},
|
||||
});
|
||||
} break;
|
||||
case 'error': {
|
||||
let error = INTERNAL_ERROR;
|
||||
if (typeof token.error === 'string') {
|
||||
error = token.error;
|
||||
} else if (typeof token.error === 'object') {
|
||||
error = JSON.stringify(token.error, null, 2);
|
||||
}
|
||||
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error,
|
||||
});
|
||||
} break;
|
||||
case 'finish': {
|
||||
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;
|
||||
}
|
||||
|
||||
// I hate you switch fallthroughs
|
||||
} break;
|
||||
case 'abort': {
|
||||
for (const activeToolCall of activeToolCalls.values()) {
|
||||
await httpClient.update('tool_calls', activeToolCall.id, {
|
||||
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;
|
||||
case 'tool-output-denied':
|
||||
todo('tool-output-denied token type', token);
|
||||
break;
|
||||
case 'start':
|
||||
case 'finish-step':
|
||||
case 'tool-input-delta':
|
||||
case 'tool-input-end':
|
||||
case 'tool-result':
|
||||
// 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 activeToolCall of activeToolCalls.values()) {
|
||||
await httpClient.update('tool_calls', activeToolCall.id, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: 'text',
|
||||
value: 'An unknown error occurred',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
completeGeneration(generationId);
|
||||
if (logFile !== undefined) logFile.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { protectRoute } from '~~/server/utils/auth';
|
||||
import { getGenerationStatus } from '~~/server/utils/generation';
|
||||
import type { GenerationStatusResponse } from '~~/server/types/chat';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const generationId = getRouterParam(event, 'id');
|
||||
|
||||
if (!generationId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing generation ID'
|
||||
});
|
||||
}
|
||||
|
||||
const generation = await getGenerationStatus(generationId);
|
||||
|
||||
if (!generation) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Generation not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (generation.userId !== event.context.user.id) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Unauthorized'
|
||||
});
|
||||
}
|
||||
|
||||
const status: GenerationStatusResponse = {
|
||||
generationId,
|
||||
status: generation.status as any,
|
||||
topicId: generation.topicId,
|
||||
content: generation.content,
|
||||
error: generation.error || undefined
|
||||
};
|
||||
|
||||
return status;
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { protectRoute } from '~~/server/utils/auth';
|
||||
import { startGeneration, addClientToGeneration, removeClientFromGeneration, sendToClient, isGenerationStreaming, getGenerationStatus } from '~~/server/utils/generation';
|
||||
import { eventHandler, setHeader, setResponseStatus } from 'h3';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { useDrizzle } from '~~/server/utils/drizzle';
|
||||
import { generations, messages } from '~~/db/schema';
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const generationId = getRouterParam(event, 'id');
|
||||
|
||||
if (!generationId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing generation ID'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch generation from database
|
||||
const generation = await getGenerationStatus(generationId);
|
||||
if (!generation) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Generation not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (generation.userId !== event.context.user.id) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Unauthorized'
|
||||
});
|
||||
}
|
||||
|
||||
setHeader(event, 'Content-Type', 'text/event-stream');
|
||||
setHeader(event, 'Cache-Control', 'no-cache');
|
||||
setHeader(event, 'Connection', 'keep-alive');
|
||||
setHeader(event, 'X-Accel-Buffering', 'no');
|
||||
|
||||
setResponseStatus(event, 200);
|
||||
|
||||
try {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// If generation is already completed, send the completed message
|
||||
if (generation.status === 'completed' && generation.messageId) {
|
||||
const db = useDrizzle();
|
||||
const [message] = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.id, generation.messageId));
|
||||
|
||||
if (message) {
|
||||
sendToClient(controller, {
|
||||
type: 'complete',
|
||||
data: message
|
||||
});
|
||||
}
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// If generation failed, send the error
|
||||
if (generation.status === 'failed') {
|
||||
sendToClient(controller, {
|
||||
type: 'error',
|
||||
data: { error: generation.error || 'Generation failed' }
|
||||
});
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// If already streaming, just add this client
|
||||
if (isGenerationStreaming(generationId)) {
|
||||
addClientToGeneration(generationId, controller);
|
||||
} else {
|
||||
// Start generation if in pending status
|
||||
if (generation.status === 'pending') {
|
||||
// Fetch the original messages context (stored in topic messages)
|
||||
const db = useDrizzle();
|
||||
const topicMessages = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.topicId, generation.topicId));
|
||||
|
||||
const chatMessages = topicMessages.map(m => ({
|
||||
type: m.isUser ? 'user' as const : ('agent' as const),
|
||||
message: m.content
|
||||
}));
|
||||
|
||||
addClientToGeneration(generationId, controller);
|
||||
await startGeneration(generationId, generation.userId, generation.topicId, chatMessages, controller);
|
||||
}
|
||||
}
|
||||
|
||||
event.node.req.on('close', () => {
|
||||
removeClientFromGeneration(generationId, controller);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Stream start error:', error);
|
||||
sendToClient(controller, {
|
||||
type: 'error',
|
||||
data: { error: 'Stream initialization failed' }
|
||||
});
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return sendStream(event, stream);
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Stream error'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { messages, topics } from "~~/db/schema";
|
||||
import { protectRoute } from "~~/server/utils/auth";
|
||||
import type { Message, Topic } from '~~/types'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
const userId = event.context.user.id;
|
||||
const topicId = getRouterParam(event, 'id');
|
||||
|
||||
if (!topicId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Topic ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(topics)
|
||||
.where(and(eq(topics.userId, userId), eq(topics.id, topicId)));
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Topic not found'
|
||||
});
|
||||
}
|
||||
|
||||
const topic = rows[0] as Topic & { messages: Message[] };
|
||||
|
||||
// Fetch messages for this topic, ordered chronologically
|
||||
topic.messages = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.topicId, topic.id))
|
||||
.orderBy(asc(messages.createdAt));
|
||||
|
||||
return topic;
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { messages, topics } from "~~/db/schema";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
const { id } = event.context.params!;
|
||||
|
||||
const [topic] = await db.select().from(topics).where(eq(topics.id, id));
|
||||
if (topic === undefined || topic.userId !== event.context.user.id) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Topic not found' });
|
||||
}
|
||||
|
||||
const { content } = await readBody(event);
|
||||
if (!content) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'No content provided' });
|
||||
}
|
||||
|
||||
const [message] = await db.insert(messages).values({
|
||||
topicId: topic.id,
|
||||
userId: event.context.user.id,
|
||||
content,
|
||||
isUser: true,
|
||||
}).returning();
|
||||
|
||||
return message;
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { topics } from "~~/db/schema";
|
||||
import { protectRoute } from "~~/server/utils/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
const userId = event.context.user.id;
|
||||
|
||||
// Only return topics for the authenticated user
|
||||
const rows = await db.select().from(topics).where(eq(topics.userId, userId));
|
||||
return rows;
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { topics, agents } from "~~/db/schema";
|
||||
import { protectRoute } from "~~/server/utils/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
const userId = event.context.user.id;
|
||||
|
||||
const body = await readBody(event);
|
||||
const { agentId, name } = body;
|
||||
|
||||
if (!agentId || !name) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Verify the agent belongs to this user
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
||||
if (!agent || agent.userId !== userId) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Agent not found or unauthorized' });
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(topics)
|
||||
.values({ userId, agentId, name })
|
||||
.returning();
|
||||
|
||||
return inserted;
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { HttpClient } from '@triplit/client';
|
||||
import { schema } from '#triplit/schema';
|
||||
|
||||
export const httpClient = new HttpClient({
|
||||
schema,
|
||||
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||
token: process.env.TRIPLIT_SERVICE_TOKEN,
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { consola } from 'consola';
|
||||
|
||||
export default defineNitroPlugin(async () => {
|
||||
consola.info('Connecting to database...');
|
||||
|
||||
try {
|
||||
const db = useDrizzle();
|
||||
await db.execute('SELECT 1');
|
||||
consola.success('Connected to database!');
|
||||
} catch (e) {
|
||||
consola.error('Connection to database failed!');
|
||||
process.kill(process.pid);
|
||||
}
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
export type MessageType = 'system' | 'agent' | 'user';
|
||||
export type GenerationStatus = 'pending' | 'active' | 'completed' | 'failed';
|
||||
|
||||
export interface ChatMessage {
|
||||
type: MessageType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GenerateRequestBody {
|
||||
topicId: string;
|
||||
messages: ChatMessage[];
|
||||
regeneratesFrom?: string;
|
||||
}
|
||||
|
||||
export interface GenerationStreamEvent {
|
||||
type: 'start' | 'token' | 'complete' | 'error';
|
||||
data: string | object | null;
|
||||
}
|
||||
|
||||
export interface GenerationStatusResponse {
|
||||
generationId: string;
|
||||
status: GenerationStatus;
|
||||
content?: string;
|
||||
topicId?: string;
|
||||
model?: string;
|
||||
tokensGenerated?: number;
|
||||
tokensUsedThinking?: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { H3Event } from 'h3';
|
||||
import type { H3Event } from 'h3';
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
export const protectRoute = async (event: H3Event) => {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "~~/db/schema";
|
||||
|
||||
export const useDrizzle = () => {
|
||||
return drizzle(process.env.DATABASE_URL!)
|
||||
}
|
||||
|
||||
export const tables = schema;
|
||||
|
||||
export const UserInsert = schema.user.$inferInsert;
|
||||
export type UserRegisterType = Omit<typeof UserInsert, "createdAt" | "updatedAt" | "id" | "emailVerified">;
|
||||
@@ -1,305 +0,0 @@
|
||||
import { useDrizzle } from '~~/server/utils/drizzle';
|
||||
import { generations, messages as messages_drizzle, messagesRelations } from '~~/db/schema';
|
||||
import { type GenerationStreamEvent, type ChatMessage, type GenerationStatus } from '~~/server/types/chat';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Streaming generation state - only stores active stream controllers
|
||||
* All persistent state lives in the database
|
||||
*/
|
||||
interface ActiveGenerationStream {
|
||||
userId: string;
|
||||
topicId: string;
|
||||
clients: Set<ReadableStreamDefaultController<Uint8Array>>;
|
||||
isGenerating: boolean;
|
||||
}
|
||||
|
||||
const activeGenerationStreams = new Map<string, ActiveGenerationStream>();
|
||||
const db = useDrizzle();
|
||||
|
||||
/**
|
||||
* Add a client connection to an active generation stream
|
||||
*/
|
||||
export const addClientToGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): boolean => {
|
||||
const stream = activeGenerationStreams.get(generationId);
|
||||
if (!stream) {
|
||||
return false;
|
||||
}
|
||||
stream.clients.add(controller);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a client connection from an active generation stream
|
||||
*/
|
||||
export const removeClientFromGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): void => {
|
||||
const stream = activeGenerationStreams.get(generationId);
|
||||
if (stream) {
|
||||
stream.clients.delete(controller);
|
||||
// Clean up if no clients left and generation is complete
|
||||
if (stream.clients.size === 0 && !stream.isGenerating) {
|
||||
activeGenerationStreams.delete(generationId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an event to all connected clients for a generation
|
||||
*/
|
||||
export const sendToClients = (generationId: string, event: GenerationStreamEvent): void => {
|
||||
const stream = activeGenerationStreams.get(generationId);
|
||||
if (!stream) return;
|
||||
|
||||
for (const client of stream.clients) {
|
||||
sendToClient(client, event);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an event to a single client
|
||||
*/
|
||||
export const sendToClient = (client: ReadableStreamDefaultController<Uint8Array>, event: GenerationStreamEvent): void => {
|
||||
const data = JSON.stringify(event);
|
||||
const encoder = new TextEncoder();
|
||||
try {
|
||||
client.enqueue(encoder.encode(`${data}\n`));
|
||||
} catch (error) {
|
||||
console.error('Failed to send to client:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a prompt from chat messages
|
||||
*/
|
||||
const buildPrompt = (messages: ChatMessage[]): string => {
|
||||
return messages
|
||||
.map((msg: ChatMessage) => {
|
||||
const roleMap: Record<typeof msg.type, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
agent: 'Assistant'
|
||||
};
|
||||
return `${roleMap[msg.type]}: ${msg.message}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new pending generation in the database
|
||||
* Returns the generation ID
|
||||
*/
|
||||
export const createPendingGeneration = async (userId: string, topicId: string, messages: ChatMessage[], regeneratesFrom?: string): Promise<string> => {
|
||||
const generationValues: any = {
|
||||
userId,
|
||||
topicId,
|
||||
status: 'pending' as GenerationStatus,
|
||||
};
|
||||
|
||||
if (regeneratesFrom) {
|
||||
generationValues.regeneratesFrom = regeneratesFrom;
|
||||
}
|
||||
|
||||
const [generation] = await db
|
||||
.insert(generations)
|
||||
.values(generationValues)
|
||||
.returning();
|
||||
|
||||
return generation.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current generation status and content from database
|
||||
*/
|
||||
export const getGenerationStatus = async (generationId: string) => {
|
||||
const [generation] = await db
|
||||
.select()
|
||||
.from(generations)
|
||||
.where(eq(generations.id, generationId));
|
||||
|
||||
return generation || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a generation: update status to active and begin streaming
|
||||
* This is called when a client connects to the stream
|
||||
*/
|
||||
export const startGeneration = async (
|
||||
generationId: string,
|
||||
userId: string,
|
||||
topicId: string,
|
||||
messages: ChatMessage[],
|
||||
controller: ReadableStreamDefaultController<Uint8Array>
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Get current generation from database
|
||||
const generation = await getGenerationStatus(generationId);
|
||||
if (!generation) {
|
||||
throw new Error('Generation not found');
|
||||
}
|
||||
|
||||
// Create active stream tracking
|
||||
activeGenerationStreams.set(generationId, {
|
||||
userId,
|
||||
topicId,
|
||||
clients: new Set([controller]),
|
||||
isGenerating: true,
|
||||
});
|
||||
|
||||
// Update status to active
|
||||
await db
|
||||
.update(generations)
|
||||
.set({
|
||||
status: 'active' as GenerationStatus,
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.where(eq(generations.id, generationId));
|
||||
|
||||
sendToClients(generationId, {
|
||||
type: 'start',
|
||||
data: null,
|
||||
});
|
||||
|
||||
const prompt = buildPrompt(messages);
|
||||
const dummyResponse = generateDummyResponse(prompt, messages);
|
||||
const tokens = dummyResponse.split(' ');
|
||||
|
||||
// Simulate token streaming
|
||||
let accumulatedContent = '';
|
||||
for (const token of tokens) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
accumulatedContent += token + ' ';
|
||||
|
||||
sendToClients(generationId, {
|
||||
type: 'token',
|
||||
data: token + ' ',
|
||||
});
|
||||
}
|
||||
|
||||
const finalContent = accumulatedContent.trim();
|
||||
|
||||
// Create message record for this generation
|
||||
const messageValues: any = {
|
||||
topicId,
|
||||
userId,
|
||||
content: finalContent,
|
||||
isUser: false,
|
||||
};
|
||||
|
||||
// If this is a regeneration, set the regeneratedFromId
|
||||
if (generation.regeneratesFrom) {
|
||||
messageValues.regeneratedFromId = generation.regeneratesFrom;
|
||||
messageValues.isRegenerated = true;
|
||||
}
|
||||
|
||||
let [message] = await db
|
||||
.insert(messages_drizzle)
|
||||
.values(messageValues)
|
||||
.returning();
|
||||
|
||||
// Update generation as completed
|
||||
await db
|
||||
.update(generations)
|
||||
.set({
|
||||
status: 'completed' as GenerationStatus,
|
||||
completedAt: new Date(),
|
||||
messageId: message.id,
|
||||
})
|
||||
.where(eq(generations.id, generationId));
|
||||
|
||||
let fmessage = await db.select().from(messages_drizzle).where(eq(messages_drizzle.userId, userId)).leftJoin(generations, eq(messages_drizzle.id, generations.messageId))
|
||||
console.log(fmessage);
|
||||
|
||||
// Mark stream as no longer generating
|
||||
const stream = activeGenerationStreams.get(generationId);
|
||||
if (stream) {
|
||||
stream.isGenerating = false;
|
||||
}
|
||||
|
||||
sendToClients(generationId, {
|
||||
type: 'complete',
|
||||
data: fmessage,
|
||||
});
|
||||
|
||||
// Close all client connections
|
||||
const finalStream = activeGenerationStreams.get(generationId);
|
||||
if (finalStream) {
|
||||
for (const client of finalStream.clients) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
// Client already closed
|
||||
}
|
||||
}
|
||||
activeGenerationStreams.delete(generationId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Generation failed:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Update generation as failed
|
||||
await db
|
||||
.update(generations)
|
||||
.set({
|
||||
status: 'failed' as GenerationStatus,
|
||||
error: errorMessage,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(generations.id, generationId));
|
||||
|
||||
sendToClients(generationId, {
|
||||
type: 'error',
|
||||
data: { error: errorMessage },
|
||||
});
|
||||
|
||||
// Close all client connections
|
||||
const stream = activeGenerationStreams.get(generationId);
|
||||
if (stream) {
|
||||
for (const client of stream.clients) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
// Client already closed
|
||||
}
|
||||
}
|
||||
activeGenerationStreams.delete(generationId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a generation is currently being streamed
|
||||
*/
|
||||
export const isGenerationStreaming = (generationId: string): boolean => {
|
||||
return activeGenerationStreams.has(generationId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a dummy response for testing
|
||||
*/
|
||||
const generateDummyResponse = (prompt: string, messages: ChatMessage[]): string => {
|
||||
const responses = [
|
||||
"This is a simulated response to your prompt. In a real implementation, this would be generated by an AI model like GPT-4 or Claude."
|
||||
+ " I'm processing your message about: " + prompt.substring(0, 50) + "... "
|
||||
+ "This dummy generation demonstrates the streaming and background save functionality.",
|
||||
|
||||
"I understand your query. This is a placeholder response that simulates AI-generated content."
|
||||
+ " The system will continue generating this response even if you close the tab, and it will"
|
||||
+ " automatically save to the database when complete.",
|
||||
|
||||
"Here's a simulated AI response. This demonstrates two key features:"
|
||||
+ " 1) The generation continues in the background even if you disconnect,"
|
||||
+ " 2) The complete response is automatically saved to the database without requiring"
|
||||
+ " a separate update request from the client."
|
||||
];
|
||||
|
||||
const lastUserMessage = messages[messages.length - 1]?.message.toLowerCase() || '';
|
||||
|
||||
if (lastUserMessage.includes('hello') || lastUserMessage.includes('hi')) {
|
||||
return "Hello! I'm a dummy AI assistant. This is a simulated response to your greeting."
|
||||
+ " In production, this would be replaced with actual AI-generated content from an LLM provider.";
|
||||
}
|
||||
|
||||
return responses[Math.floor(Math.random() * responses.length)];
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
const pendingGenerations: Record<string, AbortController> = {};
|
||||
|
||||
export const cancelPendingGeneration = (generationId: string): boolean => {
|
||||
const controller = pendingGenerations[generationId];
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
delete pendingGenerations[generationId];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const completeGeneration = (generationId: string) => {
|
||||
const controller = pendingGenerations[generationId];
|
||||
if (controller) {
|
||||
delete pendingGenerations[generationId];
|
||||
}
|
||||
};
|
||||
|
||||
export const addPendingGeneration = (generationId: string, controller: AbortController) => {
|
||||
pendingGenerations[generationId] = controller;
|
||||
};
|
||||
Reference in New Issue
Block a user