-
-
- {{ message.content }}
-
-
No messages yet
-
{{ generatingMessage }}
+
+
{{ activeAgent.name }}
+
Select a topic to continue or create a new one
-
+
\ No newline at end of file
diff --git a/app/pages/agent/[id]/profile.vue b/app/pages/agent/[id]/profile.vue
index eeefcf0..7602596 100644
--- a/app/pages/agent/[id]/profile.vue
+++ b/app/pages/agent/[id]/profile.vue
@@ -2,7 +2,7 @@
const { activeAgent: agent, updateAgent } = await useAgents();
const route = useRoute()
-if (agent.value === undefined) navigateTo('/');
+// if (agent.value === undefined) navigateTo('/');
const handleInput = (e: Event) => {
const target = e.target as HTMLInputElement;
diff --git a/app/pages/agent/[id]/topic/[topicId].vue b/app/pages/agent/[id]/topic/[topicId].vue
new file mode 100644
index 0000000..45996ae
--- /dev/null
+++ b/app/pages/agent/[id]/topic/[topicId].vue
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Agent
+
{{ generatingMessage }}
+
+
+
+
+ No messages yet. Start the conversation!
+
+
+
+
+
+
+
diff --git a/app/pages/auth/login.vue b/app/pages/auth/login.vue
index 17cfc02..3cfdaba 100644
--- a/app/pages/auth/login.vue
+++ b/app/pages/auth/login.vue
@@ -10,17 +10,32 @@ if (session.value !== null) {
}
const form = reactive({
- name: "",
email: "",
password: "",
- confirmPassword: "",
});
const loading = ref(false);
let emailInputEl = ref
(null);
let passwordInputEl = ref(null);
+let tempForm = {
+ email: "",
+ password: "",
+}
+
+// prevent text fields from clearing on hydration
+onBeforeMount(() => {
+ tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
+ tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
+})
+
+let hydrated = ref(false);
+
onMounted(() => {
+ form.email = tempForm.email;
+ form.password = tempForm.password;
+ hydrated.value = true;
+
emailInputEl.value!.addEventListener("input", () => {
emailInputEl.value!.setCustomValidity("");
});
@@ -87,8 +102,8 @@ const submit = async () => {
Password
-
-
+
+
Login
diff --git a/app/pages/auth/register.vue b/app/pages/auth/register.vue
index 6ed6ef6..52477e5 100644
--- a/app/pages/auth/register.vue
+++ b/app/pages/auth/register.vue
@@ -28,7 +28,30 @@ let emailInputEl = ref(null);
let passwordInputEl = ref(null);
let confirmPasswordInputEl = ref(null);
+let tempForm = {
+ name: "",
+ email: "",
+ password: "",
+ confirmPassword: "",
+}
+
+// prevent text fields from clearing on hydration
+onBeforeMount(() => {
+ tempForm.name = (document.getElementById("name") as HTMLInputElement)?.value ?? "";
+ tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
+ tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
+ tempForm.confirmPassword = (document.getElementById("confirmPassword") as HTMLInputElement)?.value ?? "";
+})
+
+const hydrated = ref(false);
+
onMounted(() => {
+ form.name = tempForm.name;
+ form.email = tempForm.email;
+ form.password = tempForm.password;
+ form.confirmPassword = tempForm.confirmPassword;
+ hydrated.value = true;
+
nameInputEl.value!.addEventListener("input", () => {
nameInputEl.value!.setCustomValidity("");
});
@@ -123,7 +146,7 @@ const submit = async () => {
Confirm Password
-
+
Register
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 8f5e2af..87c1497 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -96,6 +96,8 @@ onMounted(() => {
{{ animatedText }}
-
+
+
+
diff --git a/app/plugins/sync-app-state.client.ts b/app/plugins/sync-app-state.client.ts
new file mode 100644
index 0000000..a7d60fa
--- /dev/null
+++ b/app/plugins/sync-app-state.client.ts
@@ -0,0 +1,34 @@
+/**
+ * Plugin to sync appState with route changes
+ * Ensures that activeAgentId and activeTopicId stay in sync with the URL
+ */
+export default defineNuxtPlugin(() => {
+ const route = useRoute();
+ const appState = useAppState();
+
+ // Sync agent ID from route params
+ watch(
+ () => route.params.id,
+ (newId) => {
+ if (newId) {
+ const agentId = Array.isArray(newId) ? newId[0] : newId;
+ appState.setActiveAgent(agentId);
+ }
+ },
+ { immediate: true }
+ );
+
+ // Sync topic ID from route params
+ watch(
+ () => route.params.topicId,
+ (newId) => {
+ if (newId) {
+ const topicId = Array.isArray(newId) ? newId[0] : newId;
+ appState.setActiveTopic(topicId);
+ } else {
+ appState.setActiveTopic(null);
+ }
+ },
+ { immediate: true }
+ );
+});
diff --git a/app/plugins/sync-app-state.server.ts b/app/plugins/sync-app-state.server.ts
new file mode 100644
index 0000000..ab8dc60
--- /dev/null
+++ b/app/plugins/sync-app-state.server.ts
@@ -0,0 +1,14 @@
+export default defineNuxtPlugin(() => {
+ const route = useRoute();
+ const appState = useAppState();
+
+ if (route.params.id) {
+ const agentId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id;
+ appState.setActiveAgent(agentId);
+ }
+
+ if (route.params.topicId) {
+ const topicId = Array.isArray(route.params.topicId) ? route.params.topicId[0] : route.params.topicId;
+ appState.setActiveTopic(topicId);
+ }
+});
\ No newline at end of file
diff --git a/app/types/dropdown.ts b/app/types/dropdown.ts
new file mode 100644
index 0000000..e143a63
--- /dev/null
+++ b/app/types/dropdown.ts
@@ -0,0 +1,8 @@
+export interface DropdownItem {
+ label: string;
+ icon?: string;
+ onClick?: () => void;
+ value?: string | number;
+ disabled?: boolean;
+ divider?: boolean;
+}
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index c0357db..0fa297a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"better-auth": "^1.4.10",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
+ "nanoid": "^5.1.6",
"nuxt": "^4.2.2",
"pg": "^8.16.3",
"uuidv7": "^1.1.0",
@@ -1077,7 +1078,7 @@
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
- "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
@@ -1631,8 +1632,6 @@
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
- "@vue/devtools-core/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
-
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
@@ -1677,6 +1676,8 @@
"open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
+ "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
diff --git a/db/schema.ts b/db/schema.ts
index a7ded0c..d21d791 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -1,43 +1,61 @@
-import { integer, pgTable, text, boolean, timestamp } from "drizzle-orm/pg-core";
-import { uuidv7 } from "uuidv7";
+import { integer, pgTable, text, boolean, timestamp, varchar } from "drizzle-orm/pg-core";
+import { nanoid } from "nanoid";
import { user } from "./auth/auth.schema";
+import { relations } from "drizzle-orm";
export * from "./auth/auth.schema";
export const agents = pgTable("agents", {
- id: text("id").primaryKey().$defaultFn(() => 'agents_' + uuidv7()),
+ id: text("id").primaryKey().$defaultFn(() => 'agents_' + nanoid()),
userId: text("user_id").references(() => user.id).notNull(),
name: text("name").notNull(),
- systemPrompt: text("system_prompt").notNull(),
+ systemPrompt: text("system_prompt"),
imageUrl: text("image_url")
});
export const topics = pgTable("topics", {
- id: text("id").primaryKey().$defaultFn(() => 'topics_' + uuidv7()),
+ id: text("id").primaryKey().$defaultFn(() => 'topics_' + nanoid()),
userId: text("user_id").references(() => user.id).notNull(),
agentId: text("agent_id").references(() => agents.id).notNull(),
- name: text("name").notNull()
-});
-
-export const generations = pgTable("generations", {
- id: text("id").primaryKey(),
- userId: text("user_id").references(() => user.id).notNull(),
- topicId: text("topic_id").references(() => topics.id).notNull(),
- // nullable, because we insert into generations when we start a new generation,
- // and once the generation is complete we insert the complete generation into messages
- // but its currently needed to be able to fetch an entire message from its generation
- // if necessary
- messageId: text("message_id").references(() => messages.id),
+ name: text("name").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow()
});
export const messages = pgTable("messages", {
- id: text("id").primaryKey().$defaultFn(() => 'messages_' + uuidv7()),
+ id: text("id").primaryKey().$defaultFn(() => 'messages_' + nanoid()),
userId: text("user_id").references(() => user.id).notNull(),
topicId: text("topic_id").references(() => topics.id).notNull(),
- isUser: boolean("is_user").notNull(),
content: text("content").notNull(),
+ isUser: boolean("is_user").notNull(),
+ regeneratedFromId: text("regenerated_from_id").references((): any => messages.id),
+ isRegenerated: boolean("is_regenerated").default(false),
+ editedAt: timestamp("edited_at"),
+ createdAt: timestamp("created_at").notNull().defaultNow()
+});
+
+export const generations = pgTable("generations", {
+ id: text("id").primaryKey().$defaultFn(() => 'generations_' + nanoid()),
+ userId: text("user_id").references(() => user.id).notNull(),
+ topicId: text("topic_id").references(() => topics.id).notNull(),
+ status: varchar("status", { length: 20 }).notNull().default("pending"),
+ messageId: text("message_id").references(() => messages.id),
+ regeneratesFrom: text("regenerates_from").references(() => messages.id),
model: text("model"),
tokensGenerated: integer("tokens_generated"),
tokensUsedThinking: integer("tokens_used_thinking"),
- createdAt: timestamp("created_at").notNull().defaultNow()
-});
\ No newline at end of file
+ error: text("error"),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ startedAt: timestamp("started_at"),
+ completedAt: timestamp("completed_at")
+});
+
+export const messagesRelations = relations(messages, ({ one, many }) => ({
+ generations: many(generations),
+ regeneratedFrom: one(messages, {
+ fields: [messages.regeneratedFromId],
+ references: [messages.id],
+ relationName: 'regeneratedFrom'
+ }),
+ regenerations: many(messages, {
+ relationName: 'regeneratedFrom'
+ })
+}));
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 03717de..ba61e86 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -3,6 +3,7 @@ services:
postgresql:
image: pgvector/pgvector:pg17
container_name: veridian-postgres
+ command: postgres -c wal_level=logical
ports:
- "5432:5432"
volumes:
diff --git a/package.json b/package.json
index 8865f31..fc1a205 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"better-auth": "^1.4.10",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
+ "nanoid": "^5.1.6",
"nuxt": "^4.2.2",
"pg": "^8.16.3",
"uuidv7": "^1.1.0",
diff --git a/server/api/agents/index.get.ts b/server/api/agents/index.get.ts
index a6d552c..cc9f780 100644
--- a/server/api/agents/index.get.ts
+++ b/server/api/agents/index.get.ts
@@ -1,11 +1,14 @@
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;
- const rows = await db.select().from(agents);
+ // Only return agents for the authenticated user
+ const rows = await db.select().from(agents).where(eq(agents.userId, userId));
return rows;
});
diff --git a/server/api/chat/generate.post.ts b/server/api/chat/generate.post.ts
index 8e346db..612031f 100644
--- a/server/api/chat/generate.post.ts
+++ b/server/api/chat/generate.post.ts
@@ -1,12 +1,12 @@
import { protectRoute } from '~~/server/utils/auth';
-import { registerPendingGeneration } from '~~/server/utils/generation';
+import { createPendingGeneration } from '~~/server/utils/generation';
import type { GenerateRequestBody } from '~~/server/types/chat';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const body = await readBody(event) as GenerateRequestBody;
- const { topicId, messages } = body;
+ const { topicId, messages, regeneratesFrom } = body;
if (!topicId || !messages) {
throw createError({
@@ -22,12 +22,19 @@ export default defineEventHandler(async (event) => {
});
}
- const generationId = `gen_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
+ try {
+ const generationId = await createPendingGeneration(event.context.user.id, topicId, messages, regeneratesFrom);
- registerPendingGeneration(event.context.user.id, generationId, topicId, messages);
-
- return {
- generationId,
- status: 'pending'
- };
+ return {
+ generationId,
+ status: 'pending',
+ regeneratesFrom
+ };
+ } catch (error) {
+ console.error('Failed to create generation:', error);
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Failed to create generation'
+ });
+ }
});
\ No newline at end of file
diff --git a/server/api/chat/generations/[id].get.ts b/server/api/chat/generations/[id].get.ts
index 7c4c351..247dc61 100644
--- a/server/api/chat/generations/[id].get.ts
+++ b/server/api/chat/generations/[id].get.ts
@@ -1,6 +1,6 @@
import { protectRoute } from '~~/server/utils/auth';
-import { getPendingGeneration, getActiveGeneration, isGenerationActive } from '~~/server/utils/generation';
-import type { GenerationStatus } from '~~/server/types/chat';
+import { getGenerationStatus } from '~~/server/utils/generation';
+import type { GenerationStatusResponse } from '~~/server/types/chat';
export default defineEventHandler(async (event) => {
await protectRoute(event);
@@ -14,39 +14,30 @@ export default defineEventHandler(async (event) => {
});
}
- const pendingGeneration = getPendingGeneration(generationId);
- const isActive = isGenerationActive(generationId);
- const activeGeneration = getActiveGeneration(generationId);
+ const generation = await getGenerationStatus(generationId);
- if (!pendingGeneration && !activeGeneration) {
+ if (!generation) {
throw createError({
statusCode: 404,
statusMessage: 'Generation not found'
});
}
- if (pendingGeneration) {
- const status: GenerationStatus = {
- generationId,
- status: 'pending',
- topicId: pendingGeneration.topicId
- };
- return status;
+ // Verify ownership
+ if (generation.userId !== event.context.user.id) {
+ throw createError({
+ statusCode: 403,
+ statusMessage: 'Unauthorized'
+ });
}
- if (isActive && activeGeneration) {
- const status: GenerationStatus = {
- generationId,
- status: 'active',
- content: activeGeneration.content,
- topicId: activeGeneration.topicId
- };
- return status;
- }
-
- const status: GenerationStatus = {
+ const status: GenerationStatusResponse = {
generationId,
- status: 'completed'
+ status: generation.status as any,
+ topicId: generation.topicId,
+ content: generation.content,
+ error: generation.error || undefined
};
+
return status;
});
\ No newline at end of file
diff --git a/server/api/chat/stream/[id].get.ts b/server/api/chat/stream/[id].get.ts
index d642b5e..2844676 100644
--- a/server/api/chat/stream/[id].get.ts
+++ b/server/api/chat/stream/[id].get.ts
@@ -1,8 +1,9 @@
import { protectRoute } from '~~/server/utils/auth';
-import { getPendingGeneration, startGeneration, addClientToGeneration, removeClientFromGeneration, sendToClient } from '~~/server/utils/generation';
+import { startGeneration, addClientToGeneration, removeClientFromGeneration, sendToClient, isGenerationStreaming, getGenerationStatus } from '~~/server/utils/generation';
import { eventHandler, setHeader, setResponseStatus } from 'h3';
-import { generations, messages } from '~~/db/schema';
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);
@@ -16,12 +17,20 @@ export default eventHandler(async (event) => {
});
}
- const pendingGeneration = getPendingGeneration(generationId);
-
- if (pendingGeneration && pendingGeneration.expired) {
+ // Fetch generation from database
+ const generation = await getGenerationStatus(generationId);
+ if (!generation) {
throw createError({
- statusCode: 410,
- statusMessage: 'Generation expired - no client connected within 60 seconds'
+ statusCode: 404,
+ statusMessage: 'Generation not found'
+ });
+ }
+
+ // Verify ownership
+ if (generation.userId !== event.context.user.id) {
+ throw createError({
+ statusCode: 403,
+ statusMessage: 'Unauthorized'
});
}
@@ -32,36 +41,81 @@ export default eventHandler(async (event) => {
setResponseStatus(event, 200);
- const shouldStartGeneration = pendingGeneration;
-
try {
const stream = new ReadableStream({
async start(controller) {
- addClientToGeneration(generationId, 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 (shouldStartGeneration) {
- startGeneration(generationId, controller);
- } else {
- const generation = await useDrizzle().select().from(generations).where(eq(generations.id, getRouterParam(event, 'id')!))
- if (!generation) throw createError({ statusCode: 404, statusMessage: 'Generation not found' });
- const message = await useDrizzle().select().from(messages).where(eq(messages.id, generation[0].messageId!))
- if (!message) throw createError({ statusCode: 404, statusMessage: 'Message not found' });
+ 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: 'complete',
- data: message[0].content
+ type: 'error',
+ data: { error: 'Stream initialization failed' }
});
controller.close();
}
-
- event.node.req.on('close', () => {
- removeClientFromGeneration(generationId, controller);
- });
}
});
return sendStream(event, stream);
} catch (error) {
- // assume it failed because the generation is complete so try to send the message
+ console.error('Stream error:', error);
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'Stream error'
+ });
}
});
\ No newline at end of file
diff --git a/server/api/topics/[id]/index.get.ts b/server/api/topics/[id]/index.get.ts
index 0b0c2f3..9ce47e8 100644
--- a/server/api/topics/[id]/index.get.ts
+++ b/server/api/topics/[id]/index.get.ts
@@ -1,13 +1,26 @@
-import { and, desc, eq } from "drizzle-orm";
+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');
- const rows = await db.select().from(topics).where(and(eq(topics.userId, event.context.user.id), eq(topics.id, 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({
@@ -17,7 +30,13 @@ export default defineEventHandler(async (event) => {
}
const topic = rows[0] as Topic & { messages: Message[] };
- topic.messages = await db.select().from(messages).where(eq(messages.topicId, topic.id)).orderBy(desc(messages.createdAt));
+
+ // 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;
-});
\ No newline at end of file
+});
diff --git a/server/api/topics/[id]/messages/index.post.ts b/server/api/topics/[id]/messages/index.post.ts
new file mode 100644
index 0000000..9fd5ced
--- /dev/null
+++ b/server/api/topics/[id]/messages/index.post.ts
@@ -0,0 +1,29 @@
+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;
+});
\ No newline at end of file
diff --git a/server/api/topics/index.get.ts b/server/api/topics/index.get.ts
index 1812890..1b7003c 100644
--- a/server/api/topics/index.get.ts
+++ b/server/api/topics/index.get.ts
@@ -1,11 +1,14 @@
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;
- const rows = await db.select().from(topics);
+ // Only return topics for the authenticated user
+ const rows = await db.select().from(topics).where(eq(topics.userId, userId));
return rows;
});
\ No newline at end of file
diff --git a/server/api/topics/index.post.ts b/server/api/topics/index.post.ts
index 45291d6..e5533ac 100644
--- a/server/api/topics/index.post.ts
+++ b/server/api/topics/index.post.ts
@@ -1,16 +1,30 @@
-import { topics } from "~~/db/schema";
+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' });
}
- const [inserted] = await db.insert(topics).values({ userId: event.context.user.id, agentId, name }).returning();
+
+ // 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;
});
\ No newline at end of file
diff --git a/server/types/chat.ts b/server/types/chat.ts
index 6e729ef..748533d 100644
--- a/server/types/chat.ts
+++ b/server/types/chat.ts
@@ -1,4 +1,5 @@
export type MessageType = 'system' | 'agent' | 'user';
+export type GenerationStatus = 'pending' | 'active' | 'completed' | 'failed';
export interface ChatMessage {
type: MessageType;
@@ -8,19 +9,21 @@ export interface ChatMessage {
export interface GenerateRequestBody {
topicId: string;
messages: ChatMessage[];
+ regeneratesFrom?: string;
}
export interface GenerationStreamEvent {
- type: 'token' | 'complete' | 'error';
+ type: 'start' | 'token' | 'complete' | 'error';
data: string | object | null;
}
-export interface GenerationStatus {
+export interface GenerationStatusResponse {
generationId: string;
- status: 'pending' | 'active' | 'completed' | 'error';
+ status: GenerationStatus;
content?: string;
topicId?: string;
model?: string;
tokensGenerated?: number;
tokensUsedThinking?: number;
+ error?: string;
}
\ No newline at end of file
diff --git a/server/utils/generation.ts b/server/utils/generation.ts
index ba3841b..dd0942c 100644
--- a/server/utils/generation.ts
+++ b/server/utils/generation.ts
@@ -1,70 +1,63 @@
import { useDrizzle } from '~~/server/utils/drizzle';
-import { generations, messages as messages_drizzle } from '~~/db/schema';
-import { type GenerationStreamEvent, type ChatMessage, type MessageType } from '~~/server/types/chat';
+import { 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';
-interface ActiveGeneration {
+/**
+ * Streaming generation state - only stores active stream controllers
+ * All persistent state lives in the database
+ */
+interface ActiveGenerationStream {
userId: string;
topicId: string;
- messages: ChatMessage[];
- content: string;
clients: Set>;
- complete: boolean;
+ isGenerating: boolean;
}
-interface PendingGeneration {
- generationId: string;
- userId: string;
- topicId: string;
- messages: ChatMessage[];
- timeout: NodeJS.Timeout;
- expired: boolean;
-}
-
-const activeGenerations = new Map();
-const pendingGenerations = new Map();
-
-export const getActiveGeneration = (generationId: string): ActiveGeneration | undefined => {
- return activeGenerations.get(generationId);
-};
-
-export const getPendingGeneration = (generationId: string): PendingGeneration | undefined => {
- return pendingGenerations.get(generationId);
-};
-
-export const isGenerationActive = (generationId: string): boolean => {
- return activeGenerations.has(generationId);
-};
-
-export const isGenerationPending = (generationId: string): boolean => {
- return pendingGenerations.has(generationId);
-};
+const activeGenerationStreams = new Map();
+const db = useDrizzle();
+/**
+ * Add a client connection to an active generation stream
+ */
export const addClientToGeneration = (generationId: string, controller: ReadableStreamDefaultController): boolean => {
- const generation = activeGenerations.get(generationId);
- if (!generation) {
+ const stream = activeGenerationStreams.get(generationId);
+ if (!stream) {
return false;
}
- generation.clients.add(controller);
+ stream.clients.add(controller);
return true;
};
+/**
+ * Remove a client connection from an active generation stream
+ */
export const removeClientFromGeneration = (generationId: string, controller: ReadableStreamDefaultController): void => {
- const generation = activeGenerations.get(generationId);
- if (generation) {
- generation.clients.delete(controller);
- if (generation.clients.size === 0 && generation.complete) {
- activeGenerations.delete(generationId);
+ 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);
}
}
};
-const sendToClients = (generation: ActiveGeneration, event: GenerationStreamEvent): void => {
- for (const client of generation.clients) {
+/**
+ * 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, event: GenerationStreamEvent): void => {
const data = JSON.stringify(event);
const encoder = new TextEncoder();
@@ -73,12 +66,15 @@ export const sendToClient = (client: ReadableStreamDefaultController
} 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 = {
+ const roleMap: Record = {
system: 'System',
user: 'User',
agent: 'Assistant'
@@ -88,121 +84,200 @@ const buildPrompt = (messages: ChatMessage[]): string => {
.join('\n\n');
};
-const db = useDrizzle();
-
-export const startGeneration = async (generationId: string, controller: ReadableStreamDefaultController): Promise => {
- const pending = pendingGenerations.get(generationId);
-
- if (!pending) {
- console.error(`Generation ${generationId} not found in pending generations`);
- return;
- }
-
- clearTimeout(pending.timeout);
- pendingGenerations.delete(generationId);
-
- const { userId, topicId, messages } = pending;
- const prompt = buildPrompt(messages);
-
- const generation: ActiveGeneration = {
+/**
+ * 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 => {
+ const generationValues: any = {
userId,
topicId,
- messages,
- content: '',
- clients: new Set([controller]),
- complete: false
+ status: 'pending' as GenerationStatus,
};
- await db.insert(generations).values({
- id: generationId,
- userId,
- topicId,
- messageId: null
- });
+ if (regeneratesFrom) {
+ generationValues.regeneratesFrom = regeneratesFrom;
+ }
- activeGenerations.set(generationId, generation);
+ 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
+): Promise => {
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));
- generation.content += token + ' ';
+ accumulatedContent += token + ' ';
- sendToClients(generation, {
+ sendToClients(generationId, {
type: 'token',
- data: token + ' '
+ data: token + ' ',
});
}
- const [message] = await db.insert(messages_drizzle).values({
- topicId: generation.topicId,
- userId: generation.userId,
+ const finalContent = accumulatedContent.trim();
+
+ // Create message record for this generation
+ const messageValues: any = {
+ topicId,
+ userId,
+ content: finalContent,
isUser: false,
- content: generation.content.trim(),
- model: 'dummy-model-v1',
- tokensGenerated: tokens.length,
- tokensUsedThinking: 0
- }).returning();
+ };
- generation.complete = true;
+ // If this is a regeneration, set the regeneratedFromId
+ if (generation.regeneratesFrom) {
+ messageValues.regeneratedFromId = generation.regeneratesFrom;
+ messageValues.isRegenerated = true;
+ }
- sendToClients(generation, {
+ 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: message
+ data: fmessage,
});
- if (generation.clients.size === 0) {
- activeGenerations.delete(generationId);
- } else {
- generation.clients.forEach((client) => {
+ // Close all client connections
+ const finalStream = activeGenerationStreams.get(generationId);
+ if (finalStream) {
+ for (const client of finalStream.clients) {
try {
client.close();
- } catch {
+ } catch (error) {
+ // Client already closed
}
- });
- activeGenerations.delete(generationId);
+ }
+ activeGenerationStreams.delete(generationId);
}
-
- await db.update(generations).set({
- messageId: message.id
- }).where(eq(generations.id, generationId));
-
} catch (error) {
console.error('Generation failed:', error);
- sendToClients(generation, {
+ 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 instanceof Error ? error.message : 'Unknown error'
+ data: { error: errorMessage },
});
- await db.delete(generations).where(eq(generations.id, generationId));
-
- activeGenerations.delete(generationId);
+ // 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);
+ }
}
};
-export const registerPendingGeneration = (userId: string, generationId: string, topicId: string, messages: ChatMessage[]): void => {
- const timeout = setTimeout(() => {
- const gen = pendingGenerations.get(generationId)
- if (gen) gen.expired = true;
- console.log(`Generation ${generationId} expired - no client connected within 60 seconds`);
- }, 60000);
-
- pendingGenerations.set(generationId, {
- generationId,
- userId,
- topicId,
- messages,
- timeout,
- expired: false
- });
-
- console.log(`Registered pending generation ${generationId}, waiting for client connection...`);
+/**
+ * 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."
@@ -227,4 +302,4 @@ const generateDummyResponse = (prompt: string, messages: ChatMessage[]): string
}
return responses[Math.floor(Math.random() * responses.length)];
-};
\ No newline at end of file
+};