diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a7f73a --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/app/components/Dropdown.vue b/app/components/Dropdown.vue new file mode 100644 index 0000000..d0d766a --- /dev/null +++ b/app/components/Dropdown.vue @@ -0,0 +1,102 @@ + + + \ No newline at end of file diff --git a/app/components/Message.vue b/app/components/Message.vue new file mode 100644 index 0000000..cce9b7e --- /dev/null +++ b/app/components/Message.vue @@ -0,0 +1,100 @@ + + + \ No newline at end of file diff --git a/app/components/SettingsDialog.vue b/app/components/SettingsDialog.vue index 012318a..987b386 100644 --- a/app/components/SettingsDialog.vue +++ b/app/components/SettingsDialog.vue @@ -13,67 +13,79 @@ const simulateTask = () => { diff --git a/app/components/Sidenav/Header.vue b/app/components/Sidenav/Header.vue index bf74154..fb3529a 100644 --- a/app/components/Sidenav/Header.vue +++ b/app/components/Sidenav/Header.vue @@ -1,51 +1,46 @@ \ No newline at end of file diff --git a/app/components/Sidenav/HeaderAgent.vue b/app/components/Sidenav/HeaderAgent.vue index 907f7e0..3686a1f 100644 --- a/app/components/Sidenav/HeaderAgent.vue +++ b/app/components/Sidenav/HeaderAgent.vue @@ -1,9 +1,9 @@ \ No newline at end of file diff --git a/app/components/Sidenav/NavAgent.vue b/app/components/Sidenav/NavAgent.vue index bf629ee..7d8ec42 100644 --- a/app/components/Sidenav/NavAgent.vue +++ b/app/components/Sidenav/NavAgent.vue @@ -1,32 +1,36 @@ \ No newline at end of file + diff --git a/app/components/Sidenav/NavAgentTopics.vue b/app/components/Sidenav/NavAgentTopics.vue new file mode 100644 index 0000000..dcbf0cd --- /dev/null +++ b/app/components/Sidenav/NavAgentTopics.vue @@ -0,0 +1,106 @@ + + + diff --git a/app/components/Sidenav/NavHome.vue b/app/components/Sidenav/NavHome.vue index 453f1c2..2a3310c 100644 --- a/app/components/Sidenav/NavHome.vue +++ b/app/components/Sidenav/NavHome.vue @@ -1,6 +1,5 @@ \ No newline at end of file diff --git a/app/composables/useAgents.ts b/app/composables/useAgents.ts index cf49135..19cbede 100644 --- a/app/composables/useAgents.ts +++ b/app/composables/useAgents.ts @@ -3,18 +3,16 @@ import type { Agent } from '~~/types' export const useAgents = async () => { const { addTask, completeTask } = useTasks() + const appState = useAppState() const fetchingAgents = ref(false); const agents: Ref = useState('agents', () => null); + const activeAgent = computed(() => { if (agents.value === null) return; + if (!appState.activeAgentId.value) return; - const routeId = useRoute().params.id; - if (routeId === undefined) return; - - const agent = agents.value.find(agent => agent.id === routeId); - if (agent === undefined) return; - + const agent = agents.value.find(agent => agent.id === appState.activeAgentId.value); return agent; }); diff --git a/app/composables/useAppState.ts b/app/composables/useAppState.ts new file mode 100644 index 0000000..37c5249 --- /dev/null +++ b/app/composables/useAppState.ts @@ -0,0 +1,90 @@ +import type { User, Session } from 'better-auth/types'; + +/** + * Central application state composable + * Manages navigation context and critical app-level state + * This is the single source of truth for "what am I viewing" + */ +export const useAppState = () => { + // Current navigation context + const activeAgentId = useState('appState:activeAgentId', () => null); + const activeTopicId = useState('appState:activeTopicId', () => null); + + // User data + const user = useState('appState:user', () => null); + const session = useState('appState:session', () => null); + + // Loading states + const isInitializing = useState('appState:isInitializing', () => true); + const generationInProgress = useState<{ generationId: string } | null>('appState:generationInProgress', () => null); + + /** + * Set the active agent and clear the topic + */ + const setActiveAgent = (agentId: string | null | undefined) => { + activeAgentId.value = agentId || null; + // Clear topic when switching agents + activeTopicId.value = null; + }; + + /** + * Set the active topic + */ + const setActiveTopic = (topicId: string | null | undefined) => { + activeTopicId.value = topicId || null; + }; + + /** + * Set user session data + */ + const setUser = (userData: User | null) => { + user.value = userData; + }; + + /** + * Set session + */ + const setSession = (sessionData: Session | null) => { + session.value = sessionData; + }; + + /** + * Mark initialization complete + */ + const markInitialized = () => { + isInitializing.value = false; + }; + + /** + * Start a generation + */ + const startGeneration = (generationId: string) => { + generationInProgress.value = { generationId }; + }; + + /** + * End current generation + */ + const endGeneration = () => { + generationInProgress.value = null; + }; + + return { + // State + activeAgentId, + activeTopicId, + user, + session, + isInitializing, + generationInProgress, + + // Actions + setActiveAgent, + setActiveTopic, + setUser, + setSession, + markInitialized, + startGeneration, + endGeneration + }; +}; diff --git a/app/composables/useTopics.ts b/app/composables/useTopics.ts index acd4de9..b2e2663 100644 --- a/app/composables/useTopics.ts +++ b/app/composables/useTopics.ts @@ -1,17 +1,23 @@ import type { Topic } from "~~/types"; export const useTopics = async () => { + const appState = useAppState() const fetchingTopics = ref(false); - const topics: Ref = useState('topics', () => null); + const topics: Ref = useState('topics', () => null); + + /** + * Compute topics for the currently active agent + */ + const topicsForActiveAgent = computed(() => { + if (topics.value === null || !appState.activeAgentId.value) return []; + return topics.value.filter(topic => topic.agentId === appState.activeAgentId.value); + }); + const activeTopic = computed(() => { - if (topics.value === null) return; - - const routeId = useRoute().query.topicId; - if (routeId === undefined) return; - - const topic = topics.value.find(topic => topic.id === routeId); - if (topic === undefined) return; + if (topicsForActiveAgent.value.length === 0) return; + if (!appState.activeTopicId.value) return; + const topic = topicsForActiveAgent.value.find(topic => topic.id === appState.activeTopicId.value); return topic; }); @@ -19,16 +25,17 @@ export const useTopics = async () => { if (fetchingTopics.value) return; fetchingTopics.value = true; - const { data, error } = await useFetch('/api/topics'); - if (error.value) throw error; - topics.value = data.value!; - - fetchingTopics.value = false; + try { + const { data, error } = await useFetch('/api/topics'); + if (error.value) throw error; + topics.value = data.value!; + } finally { + fetchingTopics.value = false; + } } if (topics.value === null) await refreshTopics(); - const createTopic = async (name: string, agentId: string) => { const res = await fetch('/api/topics', { method: 'POST', @@ -42,8 +49,11 @@ export const useTopics = async () => { throw new Error('Failed to create topic') } - return res.json() + const newTopic = await res.json() + if (topics.value === null) topics.value = [] + topics.value.push(newTopic) + return newTopic } - return { createTopic, activeTopic, topics } + return { createTopic, activeTopic, topics, topicsForActiveAgent, fetchingTopics, refreshTopics } } \ No newline at end of file diff --git a/app/pages/agent/[id]/index.vue b/app/pages/agent/[id]/index.vue index 9dee9e0..fba0793 100644 --- a/app/pages/agent/[id]/index.vue +++ b/app/pages/agent/[id]/index.vue @@ -1,77 +1,24 @@ @@ -79,15 +26,12 @@ const handleSubmit = async (message: string) => {
-
-

- {{ 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 @@ + + + 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 () => { - 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 () => { - 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(() => { 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 +};