continue scaffolding and refine the basic foundation
This commit is contained in:
+24
@@ -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
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownItem } from '~/types/dropdown';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: DropdownItem[];
|
||||||
|
modelValue?: boolean;
|
||||||
|
placement?: 'right' | 'left' | 'center';
|
||||||
|
verticality?: 'asscending' | 'descending';
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: false,
|
||||||
|
placement: 'right',
|
||||||
|
verticality: 'descending',
|
||||||
|
width: 'auto'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void;
|
||||||
|
(e: 'select', item: DropdownItem): void;
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const triggerRef = ref<HTMLElement | null>(null)
|
||||||
|
const isOpen = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = (item: DropdownItem) => {
|
||||||
|
if (item.disabled || item.divider) return
|
||||||
|
emit('select', item)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const placementClasses = computed(() => {
|
||||||
|
let classes = ''
|
||||||
|
|
||||||
|
switch (props.placement) {
|
||||||
|
case 'right':
|
||||||
|
classes += 'right-0 '
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
classes += 'left-0 '
|
||||||
|
break
|
||||||
|
case 'center':
|
||||||
|
classes += 'left-1/2 -translate-x-1/2 '
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
classes += 'left-0 '
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.verticality) {
|
||||||
|
case 'asscending':
|
||||||
|
classes += 'bottom-full mb-1.5'
|
||||||
|
break
|
||||||
|
case 'descending':
|
||||||
|
classes += 'top-full mt-1.5'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes
|
||||||
|
})
|
||||||
|
|
||||||
|
useClickOutside(triggerRef, () => {
|
||||||
|
isOpen.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="triggerRef" :class="$attrs.class">
|
||||||
|
<slot name="trigger" :toggle="toggle" :is-open="isOpen"></slot>
|
||||||
|
|
||||||
|
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
|
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
|
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||||
|
<div v-if="isOpen" :class="[
|
||||||
|
'absolute z-50 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-1.5 flex flex-col gap-1',
|
||||||
|
placementClasses
|
||||||
|
]" :style="{ width: width !== 'auto' ? width : undefined }">
|
||||||
|
<template v-for="(item, index) in items" :key="index">
|
||||||
|
<div v-if="item.divider" class="h-px bg-[var(--color-highlight)] my-1" />
|
||||||
|
<button @click="select(item); item.onClick && item.onClick()" :disabled="item.disabled"
|
||||||
|
class="bg-transparent w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left"
|
||||||
|
:class="[
|
||||||
|
item.disabled
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'hover:bg-[var(--color-highlight)] cursor-pointer'
|
||||||
|
]">
|
||||||
|
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span class="text-sm whitespace-nowrap">{{ item.label }}</span>
|
||||||
|
<slot name="item-after" :item="item"></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Message } from '~~/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: Message;
|
||||||
|
regenerations?: Message[];
|
||||||
|
isCurrent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
regenerations: () => [],
|
||||||
|
isCurrent: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
regenerate: [messageId: string];
|
||||||
|
select: [messageId: string];
|
||||||
|
delete: [messageId: string];
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const content = computed(() => props.message.content)
|
||||||
|
const hasAlternatives = computed(() => props.regenerations.length > 0)
|
||||||
|
const showDropdown = computed(() => hasAlternatives.value || !props.isUser)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-3 relative group">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
||||||
|
:class="isUser ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-neutral)] border border-[var(--color-highlight)]'"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="isUser ? 'mynaui:user' : 'mynaui:check-hexagon'"
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="isUser ? 'text-[var(--color-accent-text)]' : 'text-[var(--color-accent)]'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<p class="text-sm font-medium" :class="isUser ? 'text-[var(--color-accent)]' : 'text-[var(--color-neutral)]'">
|
||||||
|
{{ isUser ? 'You' : 'Agent' }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Dropdown v-if="showDropdown" v-model="isOpen" placement="bottom-right" width="140px">
|
||||||
|
<template #trigger="{ toggle }">
|
||||||
|
<button
|
||||||
|
@click="toggle"
|
||||||
|
class="p-1 rounded hover:bg-[var(--color-highlight)]"
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
<Icon name="mynaui:dots-horizontal" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<button
|
||||||
|
v-if="!isUser"
|
||||||
|
@click="emit('regenerate', message.id)"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
|
||||||
|
>
|
||||||
|
<Icon name="mynaui:refresh" class="w-4 h-4" />
|
||||||
|
<span class="text-sm">Regenerate</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!isUser && hasAlternatives" class="h-px bg-[var(--color-highlight)] my-1" />
|
||||||
|
<template v-if="hasAlternatives">
|
||||||
|
<button
|
||||||
|
v-for="alt in regenerations"
|
||||||
|
:key="alt.id"
|
||||||
|
@click="emit('select', alt.id)"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
|
||||||
|
:class="message.id === alt.id ? 'bg-[var(--color-highlight)]' : ''"
|
||||||
|
>
|
||||||
|
<Icon name="mynaui:clock" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||||
|
<span class="text-sm text-[var(--color-subtle)]">{{ alt.id }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div class="h-px bg-[var(--color-highlight)] my-1" />
|
||||||
|
<button
|
||||||
|
@click="emit('delete', message.id)"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left text-red-400"
|
||||||
|
>
|
||||||
|
<Icon name="mynaui:trash" class="w-4 h-4" />
|
||||||
|
<span class="text-sm">Delete</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-[var(--color-text)] whitespace-pre-wrap break-words">{{ content }}</p>
|
||||||
|
|
||||||
|
<div v-if="editedAt" class="mt-1 text-xs text-[var(--color-subtle)] flex items-center gap-1">
|
||||||
|
<Icon name="mynaui:pencil" class="w-3 h-3" />
|
||||||
|
<span>Edited</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -13,67 +13,79 @@ const simulateTask = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="open" class="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80"
|
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" enter-from-class="opacity-0"
|
||||||
@click.self="close">
|
enter-to-class="opacity-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||||
<div class="w-[70vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
|
<div v-if="open" class="fixed inset-0 z-45 bg-black/80" @click.self="close">
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
|
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||||
|
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
|
||||||
|
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
|
||||||
|
<div class="absolute w-[70vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
|
||||||
overflow-hidden flex max-h-[90vh] p-2">
|
overflow-hidden flex max-h-[90vh] p-2">
|
||||||
<!-- Sidebar Nav -->
|
<!-- Sidebar Nav -->
|
||||||
<nav class="w-64 flex flex-col gap-2">
|
<nav class="w-64 flex flex-col gap-2">
|
||||||
<div class="flex items-center justify-between pb-4">
|
<div class="flex items-center justify-between pb-4">
|
||||||
<div class="flex items-center gap-2 px-2">
|
<div class="flex items-center gap-2 px-2">
|
||||||
<Icon name="mynaui:cog-four" class="w-7 h-7 text-[var(--color-subtle)] mt-1" />
|
<Icon name="mynaui:cog-four" class="w-7 h-7 text-[var(--color-subtle)] mt-1" />
|
||||||
<h1 class="font-semibold text-center">Settings</h1>
|
<h1 class="font-semibold text-center">Settings</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-for="page in pages" :key="page" :class="[
|
||||||
<div v-for="page in pages" :key="page" :class="[
|
'select-none cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left',
|
||||||
'select-none cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left',
|
currentPage === page ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]/10'
|
||||||
currentPage === page ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]/10'
|
]" @click="setPage(page)">
|
||||||
]" @click="setPage(page)">
|
{{ page.charAt(0).toUpperCase() + page.slice(1) }}
|
||||||
{{ page.charAt(0).toUpperCase() + page.slice(1) }}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<main class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<header class="flex items-center justify-between pl-2 pb-2">
|
|
||||||
<h2 class="text-lg font-semibold">{{ currentPage.charAt(0).toUpperCase() + currentPage.slice(1) }}
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div id="settings-loader-target"></div>
|
|
||||||
<button @click="close"
|
|
||||||
class="p-1.5 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)]/10 transition-colors">
|
|
||||||
<Icon name="mynaui:x-solid" class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</nav>
|
||||||
|
|
||||||
<div
|
<!-- Content -->
|
||||||
class="flex-1 p-6 ml-1 mt-1 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
|
<main class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div v-if="currentPage === 'page1'">
|
<header class="flex items-center justify-between pl-2 pb-2">
|
||||||
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 1 content. Try adding a task to
|
<h2 class="text-lg font-semibold">{{ currentPage.charAt(0).toUpperCase() + currentPage.slice(1)
|
||||||
the queue
|
}}
|
||||||
below.
|
</h2>
|
||||||
</p>
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex gap-2">
|
<div id="settings-loader-target"></div>
|
||||||
<button class="accent" @click="simulateTask">
|
<button @click="close"
|
||||||
Simulate Task (2s)
|
class="p-1.5 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)]/10 transition-colors">
|
||||||
|
<Icon name="mynaui:x-solid" class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
<div v-else-if="currentPage === 'page2'">
|
|
||||||
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 2 content with some details.
|
<div
|
||||||
</p>
|
class="flex-1 p-6 ml-1 mt-1 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
|
||||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
<div v-if="currentPage === 'page1'">
|
||||||
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 1</div>
|
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 1 content. Try adding a
|
||||||
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 2</div>
|
task to
|
||||||
|
the queue
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="accent" @click="simulateTask">
|
||||||
|
Simulate Task (2s)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentPage === 'page2'">
|
||||||
|
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 2 content with some
|
||||||
|
details.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 1</div>
|
||||||
|
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentPage === 'page3'">
|
||||||
|
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 3 content.</p>
|
||||||
|
<button class="accent" @click="simulateTask">Run Task</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="currentPage === 'page3'">
|
</main>
|
||||||
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 3 content.</p>
|
</div>
|
||||||
<button class="accent" @click="simulateTask">Run Task</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,51 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { DropdownItem } from '~/types/dropdown'
|
||||||
|
|
||||||
const { user, signOut } = useAuth()
|
const { user, signOut } = useAuth()
|
||||||
const { toggle: toggleSettings } = useSettings()
|
const { toggle: toggleSettings } = useSettings()
|
||||||
const profileRef = ref<HTMLElement | null>(null)
|
|
||||||
const profileOpen = ref(false)
|
|
||||||
|
|
||||||
const hovering = defineModel<boolean>({ required: true })
|
const hovering = defineModel<boolean>({ required: true })
|
||||||
|
|
||||||
const toggleProfile = () => {
|
const profileOpen = ref(false)
|
||||||
profileOpen.value = !profileOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut()
|
await signOut()
|
||||||
profileOpen.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useClickOutside(profileRef, () => {
|
const profileItems: DropdownItem[] = [
|
||||||
profileOpen.value = false
|
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
|
||||||
})
|
{ label: 'Log out', icon: 'mynaui:logout', divider: true, onClick: handleLogout },
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="flex items-center justify-between overflow-hidden">
|
<header class="flex items-center justify-between overflow-hidden">
|
||||||
<div role="button" aria-label="open user dropdown" ref="profileRef"
|
<Dropdown v-model="profileOpen" :items="profileItems" placement="left" verticality="descending" width="100%">
|
||||||
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
|
<template #trigger="{ toggle }">
|
||||||
@click="toggleProfile">
|
<div role="button" aria-label="open user dropdown"
|
||||||
<div
|
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
|
||||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
|
@click="toggle">
|
||||||
<img v-if="user?.image" :src="user.image" class="w-full h-full object-cover" />
|
<div
|
||||||
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-subtle)]" />
|
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
|
||||||
</div>
|
<img v-if="user?.image" :src="user.image" class="w-full h-full object-cover" />
|
||||||
<span
|
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
|
</div>
|
||||||
user!.name
|
<span
|
||||||
}}</span>
|
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
|
||||||
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-subtle)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
|
user!.name
|
||||||
hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-x-0 scale-y-90'
|
}}</span>
|
||||||
]">
|
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-subtle)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
|
||||||
<Icon class="text-4" name="mynaui:chevron-down" />
|
hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-x-0 scale-y-90'
|
||||||
</div>
|
]">
|
||||||
</div>
|
<Icon class="text-4" name="mynaui:chevron-down" />
|
||||||
|
</div>
|
||||||
<!-- Profile Dropdown -->
|
</div>
|
||||||
<div v-show="profileOpen"
|
</template>
|
||||||
class="absolute top-full left-0 right-0 z-50 mt-1.5 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-2 w-full gap-1 flex flex-col text-[var(--color-text)]">
|
</Dropdown>
|
||||||
<SidenavItem @click="toggleSettings(); profileOpen = false" name="Settings" icon="mynaui:cog-four" />
|
|
||||||
<SidenavItem @click="handleLogout" name="Log out" icon="mynaui:logout" />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { DropdownItem } from '~/types/dropdown'
|
||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { agents, activeAgent } = await useAgents()
|
const { agents, activeAgent } = await useAgents()
|
||||||
const homeButtonRef = ref<HTMLElement | null>(null)
|
const homeButtonRef = ref<HTMLElement | null>(null)
|
||||||
const agentDropdownRef = ref<HTMLElement | null>(null)
|
|
||||||
const agentDropdownOpen = ref(false)
|
|
||||||
|
|
||||||
const hovering = defineModel<boolean>({ required: true })
|
const hovering = defineModel<boolean>({ required: true })
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
@@ -26,11 +26,11 @@ onMounted(() => {
|
|||||||
homeButtonRef.value!.style.width = '0'
|
homeButtonRef.value!.style.width = '0'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
|
||||||
|
|
||||||
useClickOutside(agentDropdownRef, () => {
|
|
||||||
agentDropdownOpen.value = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const agentDropdownOpen = ref(false)
|
||||||
|
|
||||||
|
const agentItems: DropdownItem[] = []
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,33 +43,36 @@ useClickOutside(agentDropdownRef, () => {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
|
<Dropdown v-model="agentDropdownOpen" :items="agentItems" placement="center" width="calc(80% - 1rem)">
|
||||||
ref="agentDropdownRef" @click="agentDropdownOpen = !agentDropdownOpen">
|
<template #trigger="{ toggle }">
|
||||||
<div
|
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
|
||||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
|
@click="toggle">
|
||||||
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl" class="w-full h-full object-cover" />
|
<div
|
||||||
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-highlight-high)]']">
|
||||||
</div>
|
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
|
||||||
<span class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
|
class="w-full h-full object-cover" />
|
||||||
{{ activeAgent?.name }}
|
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||||
</span>
|
</div>
|
||||||
<div class="w-4 h-4 text-[var(--color-subtle)]">
|
<span
|
||||||
<Icon
|
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
|
||||||
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
{{ activeAgent?.name }}
|
||||||
name="mynaui:chevron-up-down" />
|
</span>
|
||||||
</div>
|
<div class="w-4 h-4 text-[var(--color-subtle)]">
|
||||||
</div>
|
<Icon
|
||||||
|
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
||||||
<!-- Agent Dropdown -->
|
name="mynaui:chevron-up-down" />
|
||||||
<div v-show="agentDropdownOpen" class="absolute top-full left-1/10 z-50 mt-1.5 bg-[var(--color-neutral)] border border-[var(--color-highlight)]
|
</div>
|
||||||
rounded-xl p-2 w-8/10">
|
</div>
|
||||||
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
|
</template>
|
||||||
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id" :class="['decoration-none whitespace-nowrap', activeAgent?.id === agent.id ? 'text-[var(--color-text)]' :
|
<template #content>
|
||||||
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
|
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
|
||||||
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
|
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id" :class="['decoration-none whitespace-nowrap', activeAgent?.id === agent.id ? 'text-[var(--color-text)]' :
|
||||||
:active="activeAgent?.id === agent.id" />
|
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
|
||||||
</NuxtLink>
|
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
|
||||||
</div>
|
:active="activeAgent?.id === agent.id" />
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,32 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
|
|
||||||
const routeParts = computed(() => {
|
const routeParts = computed(() => {
|
||||||
return route.path.replace('/agent/', '').split('/')
|
return route.path.replace('/agent/', '').split('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (routeParts.value.length < 1) navigateTo('/')
|
if (routeParts.value.length < 1) navigateTo('/');
|
||||||
if (!routeParts.value[0]!.match(/^agents_[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/)) navigateTo('/')
|
|
||||||
|
|
||||||
const pageInfo = computed(() => {
|
const pageInfo = computed(() => {
|
||||||
// `/agent/agent_[uuid]`
|
// `/agent/agent_[uuid]` or `/agent/agent_[uuid]/topic/...`
|
||||||
if (routeParts.value.length === 1) {
|
if (route.path.startsWith('/agent/') && !route.path.includes('/profile')) {
|
||||||
return 'new-conversation'
|
return 'conversation';
|
||||||
}
|
}
|
||||||
|
|
||||||
// `/agent/agent_[uuid]/profile`
|
// `/agent/agent_[uuid]/profile`
|
||||||
if (routeParts.value.length === 2 && routeParts.value[1]! === 'profile') {
|
if (route.path.endsWith('/profile')) {
|
||||||
return 'agent-profile'
|
return 'agent-profile';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="flex flex-col gap-1">
|
<nav class="flex flex-col gap-1">
|
||||||
|
|
||||||
|
<!-- Agent Info Link -->
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<NuxtLink :to="`/agent/${routeParts[0]}/profile`" class="decoration-none text-[var(--color-subtle)]">
|
<NuxtLink :to="`/agent/${routeParts[0]}/profile`" class="decoration-none text-[var(--color-subtle)]">
|
||||||
<SidenavItem name="Agent Info" icon="mynaui:info-square" :active="pageInfo === 'agent-profile'" />
|
<SidenavItem name="Agent Info" icon="mynaui:info-square" :active="pageInfo === 'agent-profile'" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Topics Section -->
|
||||||
|
<SidenavNavAgentTopics />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute();
|
||||||
|
const appState = useAppState();
|
||||||
|
const topicsListRef = ref<HTMLElement | null>(null);
|
||||||
|
const topicsListHeight = ref('auto');
|
||||||
|
const topicsListOpacity = ref(1);
|
||||||
|
const topicsListScale = ref(1);
|
||||||
|
const { topicsForActiveAgent, createTopic } = await useTopics();
|
||||||
|
const { activeAgent } = await useAgents();
|
||||||
|
|
||||||
|
const creatingTopic = ref(false);
|
||||||
|
const topicsOpen = ref(true);
|
||||||
|
|
||||||
|
function easeInOutQuad(x: number): number {
|
||||||
|
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAgentsList = () => {
|
||||||
|
if (!topicsListRef.value) return;
|
||||||
|
let animationLength = 200;
|
||||||
|
let animationStart: number | null = null;
|
||||||
|
|
||||||
|
let startHeight: number;
|
||||||
|
let startOpacity = topicsListOpacity.value;
|
||||||
|
let startScale = topicsListScale.value;
|
||||||
|
if (topicsListHeight.value === 'auto') {
|
||||||
|
startHeight = topicsListRef.value.clientHeight;
|
||||||
|
} else {
|
||||||
|
startHeight = Number(topicsListHeight.value.replace('px', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
|
||||||
|
let targetOpacity = topicsOpen.value ? 0 : 1;
|
||||||
|
let targetScale = topicsOpen.value ? 0.95 : 1;
|
||||||
|
topicsOpen.value = !topicsOpen.value;
|
||||||
|
|
||||||
|
const animate = (timestamp: number) => {
|
||||||
|
if (!animationStart) animationStart = timestamp;
|
||||||
|
|
||||||
|
const elapsed = timestamp - animationStart;
|
||||||
|
const progress = Math.min(elapsed / animationLength, 1);
|
||||||
|
|
||||||
|
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
|
||||||
|
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
|
||||||
|
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
|
||||||
|
|
||||||
|
topicsListOpacity.value = currentOpacity;
|
||||||
|
topicsListScale.value = currentScale;
|
||||||
|
topicsListHeight.value = `${currentHeight}px`;
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
if (topicsOpen.value) {
|
||||||
|
topicsListHeight.value = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTopic = async () => {
|
||||||
|
if (!activeAgent.value) return;
|
||||||
|
|
||||||
|
creatingTopic.value = true;
|
||||||
|
try {
|
||||||
|
const newTopic = await createTopic('New Topic', activeAgent.value.id);
|
||||||
|
// Navigate to new topic
|
||||||
|
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create topic:', error);
|
||||||
|
} finally {
|
||||||
|
creatingTopic.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<!-- Header -->
|
||||||
|
<button @click="toggleAgentsList"
|
||||||
|
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left">
|
||||||
|
<span class="text-sm font-medium">Topics</span>
|
||||||
|
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div ref="topicsListRef"
|
||||||
|
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
|
||||||
|
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
|
||||||
|
<button @click="newTopic" :disabled="creatingTopic"
|
||||||
|
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
||||||
|
<div class="h-7 w-7 flex items-center justify-center">
|
||||||
|
<Icon v-if="creatingTopic" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||||
|
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||||
|
</div>
|
||||||
|
<span>{{ creatingTopic ? 'Creating...' : 'New Topic' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NuxtLink v-for="topic in topicsForActiveAgent" :to="`/agent/${activeAgent?.id}/topic/${topic.id}`"
|
||||||
|
class="decoration-none text-[var(--color-subtle)]">
|
||||||
|
<SidenavItem :name="topic.name" icon="mynaui:check-hexagon" />
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { agents, createAgent } = await useAgents()
|
const { agents, createAgent } = await useAgents()
|
||||||
const route = useRoute()
|
|
||||||
const agentsListRef = ref<HTMLElement | null>(null)
|
const agentsListRef = ref<HTMLElement | null>(null)
|
||||||
const agentsOpen = ref(true)
|
const agentsOpen = ref(true)
|
||||||
const agentsListHeight = ref('auto')
|
const agentsListHeight = ref('auto')
|
||||||
@@ -73,30 +72,29 @@ const newAgent = async () => {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Agents Section -->
|
<!-- Agents Section -->
|
||||||
<div class="relative group">
|
<div class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left"
|
||||||
<div class="flex items-center justify-between px-2 h-9 rounded-lg hover:bg-[var(--color-highlight)] transition-colors cursor-pointer"
|
@click="toggleAgentsList()">
|
||||||
@click="toggleAgentsList()">
|
<span class="text-sm">Agents</span>
|
||||||
<div class="flex items-center gap-0.5">
|
<Icon name="mynaui:chevron-down"
|
||||||
<span class="text-sm">Agents</span>
|
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" />
|
||||||
<Icon name="mynaui:chevron-right-solid"
|
</div>
|
||||||
:class="['transition-transform duration-200 ease-in-out transform-origin-center', agentsOpen ? 'rotate-90' : '']" />
|
|
||||||
</div>
|
|
||||||
<button aria-label="create new agent"
|
|
||||||
class="opacity-0 group-hover:opacity-100 p-1 rounded-md transition-all bg-transparent hover:bg-[var(--color-highlight)] text-[var(--color-subtle)] active:text-[var(--color-text)]"
|
|
||||||
@click.stop="newAgent">
|
|
||||||
<Icon v-if="!creatingAgent" name="mynaui:plus" class="w-4 h-4" />
|
|
||||||
<Icon v-else name="svg-spinners:ring-resize" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="agentsListRef"
|
<div ref="agentsListRef"
|
||||||
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
|
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
|
||||||
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
|
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
|
||||||
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
|
<button @click="newAgent" :disabled="creatingAgent"
|
||||||
class="decoration-none text-[var(--color-subtle)]">
|
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
||||||
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
|
<div class="h-7 w-7 flex items-center justify-center">
|
||||||
</NuxtLink>
|
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||||
</div>
|
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||||
|
</div>
|
||||||
|
<span>New Agent</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
|
||||||
|
class="decoration-none text-[var(--color-subtle)]">
|
||||||
|
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -78,7 +78,8 @@ const navKind = computed(() => {
|
|||||||
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
|
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="relative flex flex-row gap-2 justify-between items-center pb-1.5">
|
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
|
||||||
|
|
||||||
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
|
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
|
||||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" v-model="hovering" />
|
<SidenavHeaderAgent v-else-if="navKind === 'agent'" v-model="hovering" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { DropdownItem } from '~/types/dropdown'
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system'
|
type Theme = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
const isOpen = ref(false)
|
|
||||||
const buttonRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
const themeOptions: { value: Theme; label: string; icon: string }[] = [
|
const themeOptions: DropdownItem[] = [
|
||||||
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
||||||
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
||||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
|
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
|
||||||
@@ -15,38 +15,23 @@ const currentOption = computed(() =>
|
|||||||
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
|
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectTheme = (newTheme: Theme) => {
|
const isOpen = ref(false)
|
||||||
colorMode.preference = newTheme
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
const selectTheme = (item: DropdownItem) => {
|
||||||
isOpen.value = !isOpen.value
|
colorMode.preference = item.value as Theme
|
||||||
}
|
}
|
||||||
|
|
||||||
useClickOutside(buttonRef, () => {
|
|
||||||
isOpen.value = false
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="buttonRef" class="relative">
|
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
|
||||||
<button aria-label="Open theme switcher" @click="toggleDropdown"
|
placement="right" width="140px">
|
||||||
class="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
|
<template #trigger="{ toggle, isOpen }">
|
||||||
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
|
<button aria-label="Open theme switcher" @click="toggle"
|
||||||
<Icon :name="currentOption!.icon"
|
class="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
|
||||||
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
|
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
|
||||||
</button>
|
<Icon :name="currentOption!.icon!"
|
||||||
|
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
|
||||||
<div v-if="isOpen"
|
|
||||||
class="flex flex-col gap-1 absolute bottom-full mb-2 right-0 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-lg p-1 min-w-[120px] shadow-lg">
|
|
||||||
<button v-for="option in themeOptions" :key="option.value" @click="selectTheme(option.value)"
|
|
||||||
class="w-full flex items-center justify-left gap-2 px-3 py-2 rounded-md hover:bg-[var(--color-highlight)] transition-colors"
|
|
||||||
:class="colorMode.preference === option.value ? 'bg-[var(--color-highlight)] text-[var(--color-text)]' :
|
|
||||||
'bg-transparent text-[var(--color-subtle)]'">
|
|
||||||
<Icon :name="option.icon" class="w-4 h-4" />
|
|
||||||
<span class="text-sm">{{ option.label }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
@@ -3,18 +3,16 @@ import type { Agent } from '~~/types'
|
|||||||
|
|
||||||
export const useAgents = async () => {
|
export const useAgents = async () => {
|
||||||
const { addTask, completeTask } = useTasks()
|
const { addTask, completeTask } = useTasks()
|
||||||
|
const appState = useAppState()
|
||||||
|
|
||||||
const fetchingAgents = ref(false);
|
const fetchingAgents = ref(false);
|
||||||
const agents: Ref<Agent[] | null> = useState('agents', () => null);
|
const agents: Ref<Agent[] | null> = useState('agents', () => null);
|
||||||
|
|
||||||
const activeAgent = computed(() => {
|
const activeAgent = computed(() => {
|
||||||
if (agents.value === null) return;
|
if (agents.value === null) return;
|
||||||
|
if (!appState.activeAgentId.value) return;
|
||||||
|
|
||||||
const routeId = useRoute().params.id;
|
const agent = agents.value.find(agent => agent.id === appState.activeAgentId.value);
|
||||||
if (routeId === undefined) return;
|
|
||||||
|
|
||||||
const agent = agents.value.find(agent => agent.id === routeId);
|
|
||||||
if (agent === undefined) return;
|
|
||||||
|
|
||||||
return agent;
|
return agent;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<string | null>('appState:activeAgentId', () => null);
|
||||||
|
const activeTopicId = useState<string | null>('appState:activeTopicId', () => null);
|
||||||
|
|
||||||
|
// User data
|
||||||
|
const user = useState<User | null>('appState:user', () => null);
|
||||||
|
const session = useState<Session | null>('appState:session', () => null);
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const isInitializing = useState<boolean>('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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
import type { Topic } from "~~/types";
|
import type { Topic } from "~~/types";
|
||||||
|
|
||||||
export const useTopics = async () => {
|
export const useTopics = async () => {
|
||||||
|
const appState = useAppState()
|
||||||
const fetchingTopics = ref(false);
|
const fetchingTopics = ref(false);
|
||||||
const topics: Ref<Topic[] | null> = useState('topics', () => null);
|
const topics: Ref<any[] | null> = 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(() => {
|
const activeTopic = computed(() => {
|
||||||
if (topics.value === null) return;
|
if (topicsForActiveAgent.value.length === 0) return;
|
||||||
|
if (!appState.activeTopicId.value) return;
|
||||||
const routeId = useRoute().query.topicId;
|
|
||||||
if (routeId === undefined) return;
|
|
||||||
|
|
||||||
const topic = topics.value.find(topic => topic.id === routeId);
|
|
||||||
if (topic === undefined) return;
|
|
||||||
|
|
||||||
|
const topic = topicsForActiveAgent.value.find(topic => topic.id === appState.activeTopicId.value);
|
||||||
return topic;
|
return topic;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,16 +25,17 @@ export const useTopics = async () => {
|
|||||||
if (fetchingTopics.value) return;
|
if (fetchingTopics.value) return;
|
||||||
fetchingTopics.value = true;
|
fetchingTopics.value = true;
|
||||||
|
|
||||||
const { data, error } = await useFetch('/api/topics');
|
try {
|
||||||
if (error.value) throw error;
|
const { data, error } = await useFetch('/api/topics');
|
||||||
topics.value = data.value!;
|
if (error.value) throw error;
|
||||||
|
topics.value = data.value!;
|
||||||
fetchingTopics.value = false;
|
} finally {
|
||||||
|
fetchingTopics.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topics.value === null) await refreshTopics();
|
if (topics.value === null) await refreshTopics();
|
||||||
|
|
||||||
|
|
||||||
const createTopic = async (name: string, agentId: string) => {
|
const createTopic = async (name: string, agentId: string) => {
|
||||||
const res = await fetch('/api/topics', {
|
const res = await fetch('/api/topics', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -42,8 +49,11 @@ export const useTopics = async () => {
|
|||||||
throw new Error('Failed to create topic')
|
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 }
|
||||||
}
|
}
|
||||||
@@ -1,77 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Message } from '~~/types'
|
const { createTopic, fetchingTopics } = await useTopics();
|
||||||
|
const { activeAgent } = await useAgents();
|
||||||
|
|
||||||
const { createTopic, activeTopic } = await useTopics()
|
const creatingTopic = ref(false);
|
||||||
const { activeAgent } = await useAgents()
|
|
||||||
const loading = ref(false)
|
|
||||||
const messages = useState<Message[]>('messages', () => [])
|
|
||||||
const generatingMessage = ref('')
|
|
||||||
|
|
||||||
if (activeTopic.value !== undefined) {
|
|
||||||
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`)
|
|
||||||
if (topicData.error.value) throw topicData.error
|
|
||||||
messages.value = topicData.data.value!.messages
|
|
||||||
console.log(messages.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (message: string) => {
|
const handleSubmit = async (message: string) => {
|
||||||
loading.value = true
|
// create a new topic, and send the message to it
|
||||||
let topic;
|
console.log('handleSubmit', message);
|
||||||
if (activeTopic.value) {
|
if (!activeAgent.value) return;
|
||||||
topic = activeTopic.value
|
|
||||||
} else {
|
creatingTopic.value = true
|
||||||
topic = await createTopic('New Topic', activeAgent.value!.id)
|
try {
|
||||||
|
const newTopic = await createTopic('New Topic', activeAgent.value.id)
|
||||||
|
// Navigate to the new topic
|
||||||
|
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create topic:', error)
|
||||||
|
} finally {
|
||||||
|
creatingTopic.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(topic.id)
|
|
||||||
|
|
||||||
const generation = await $fetch(`/api/chat/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
topicId: topic.id,
|
|
||||||
messages: [{
|
|
||||||
type: 'user',
|
|
||||||
message
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
|
|
||||||
method: 'get',
|
|
||||||
responseType: 'stream',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a new ReadableStream from the response with TextDecoderStream to get the data as text
|
|
||||||
const reader = response.pipeThrough(new TextDecoderStream()).getReader()
|
|
||||||
|
|
||||||
generatingMessage.value = ''
|
|
||||||
|
|
||||||
// Read the data from the stream and update the UI
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const { type, data } = JSON.parse(value)
|
|
||||||
|
|
||||||
if (type === 'token') {
|
|
||||||
generatingMessage.value += data
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'complete') {
|
|
||||||
if (generatingMessage.value !== data) {
|
|
||||||
generatingMessage.value = data
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,15 +26,12 @@ const handleSubmit = async (message: string) => {
|
|||||||
<div class="flex h-full w-full pb-4 justify-center">
|
<div class="flex h-full w-full pb-4 justify-center">
|
||||||
<div class="max-w-4xl h-full w-full flex flex-col">
|
<div class="max-w-4xl h-full w-full flex flex-col">
|
||||||
<!-- chat pane -->
|
<!-- chat pane -->
|
||||||
<div class="flex h-full flex-col gap-6">
|
<div class="flex h-full flex-col gap-2 justify-end mb-28">
|
||||||
<p v-if="activeTopic !== undefined" v-for="message in messages" :key="message.id">
|
<h1 v-if="activeAgent" class="font-bold">{{ activeAgent.name }}</h1>
|
||||||
{{ message.content }}
|
<p class="text-[var(--color-subtle)]">Select a topic to continue or create a new one</p>
|
||||||
</p>
|
|
||||||
<p v-else>No messages yet</p>
|
|
||||||
<p v-if="generatingMessage" class="text-sm text-gray-500">{{ generatingMessage }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatInput @submit="handleSubmit" :loading="loading" />
|
<ChatInput @submit="handleSubmit" :loading="fetchingTopics" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
const { activeAgent: agent, updateAgent } = await useAgents();
|
const { activeAgent: agent, updateAgent } = await useAgents();
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
if (agent.value === undefined) navigateTo('/');
|
// if (agent.value === undefined) navigateTo('/');
|
||||||
|
|
||||||
const handleInput = (e: Event) => {
|
const handleInput = (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Message } from '~~/types';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const appState = useAppState();
|
||||||
|
const { activeTopic, topicsForActiveAgent } = await useTopics();
|
||||||
|
const { activeAgent } = await useAgents();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const messages = useState<Message[]>('messages', () => []);
|
||||||
|
const generatingMessage = ref('');
|
||||||
|
|
||||||
|
// Fetch messages for this topic
|
||||||
|
if (activeTopic.value) {
|
||||||
|
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`);
|
||||||
|
if (topicData.error.value) {
|
||||||
|
console.error('Failed to load topic:', topicData.error.value);
|
||||||
|
} else if (topicData.data.value?.messages) {
|
||||||
|
messages.value = topicData.data.value.messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (message: string) => {
|
||||||
|
if (!activeTopic.value) {
|
||||||
|
console.error('No active topic');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add user message to local state
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: `temp_${Date.now()}`,
|
||||||
|
topicId: activeTopic.value.id,
|
||||||
|
userId: '',
|
||||||
|
content: message,
|
||||||
|
isUser: true,
|
||||||
|
regeneratedFromId: null,
|
||||||
|
isRegenerated: false,
|
||||||
|
editedAt: null,
|
||||||
|
createdAt: new Date() as any
|
||||||
|
};
|
||||||
|
messages.value.push(userMessage);
|
||||||
|
|
||||||
|
// Create generation
|
||||||
|
const generation = await $fetch(`/api/chat/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
topicId: activeTopic.value.id,
|
||||||
|
messages: messages.value
|
||||||
|
.filter(m => m.content)
|
||||||
|
.map(m => ({
|
||||||
|
type: m.isUser ? 'user' : 'agent',
|
||||||
|
message: m.content
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
appState.startGeneration(generation.generationId);
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
|
||||||
|
generatingMessage.value = '';
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
while (!hasError) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = value.split('\n').filter(line => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
const event = JSON.parse(line);
|
||||||
|
|
||||||
|
if (event.type === 'start') {
|
||||||
|
generatingMessage.value = '';
|
||||||
|
} else if (event.type === 'token') {
|
||||||
|
generatingMessage.value += event.data;
|
||||||
|
} else if (event.type === 'complete') {
|
||||||
|
// Add the completed message to the list
|
||||||
|
console.log(event, event.data);
|
||||||
|
if (event.data) {
|
||||||
|
messages.value.concat(event.data);
|
||||||
|
generatingMessage.value = '';
|
||||||
|
}
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
console.error('Generation error:', event.data);
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Failed to parse event:', parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.endGeneration();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit message:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async (messageId: string) => {
|
||||||
|
if (!activeTopic.value) return;
|
||||||
|
|
||||||
|
// Find the user message before this one to regenerate context
|
||||||
|
const messageIndex = messages.value.findIndex(m => m.id === messageId);
|
||||||
|
if (messageIndex === -1) return;
|
||||||
|
|
||||||
|
const previousUserMessage = messages.value[messageIndex - 1];
|
||||||
|
if (!previousUserMessage) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create generation with previous user message
|
||||||
|
const generation = await $fetch(`/api/chat/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
topicId: activeTopic.value.id,
|
||||||
|
regeneratesFrom: messageId,
|
||||||
|
messages: messages.value.slice(0, messageIndex)
|
||||||
|
.map(m => ({
|
||||||
|
type: m.isUser ? 'user' : 'agent',
|
||||||
|
message: m.content
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
appState.startGeneration(generation.generationId);
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
|
||||||
|
generatingMessage.value = '';
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
while (!hasError) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = value.split('\n').filter(line => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
const event = JSON.parse(line);
|
||||||
|
|
||||||
|
if (event.type === 'start') {
|
||||||
|
generatingMessage.value = '';
|
||||||
|
} else if (event.type === 'token') {
|
||||||
|
generatingMessage.value += event.data;
|
||||||
|
} else if (event.type === 'complete') {
|
||||||
|
// Add the new regenerated message
|
||||||
|
if (event.data) {
|
||||||
|
messages.value.push(event.data);
|
||||||
|
generatingMessage.value = '';
|
||||||
|
}
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
console.error('Generation error:', event.data);
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Failed to parse event:', parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.endGeneration();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to regenerate:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRegeneration = (messageId: string) => {
|
||||||
|
const index = messages.value.findIndex(m => m.id === messageId);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
// In a real app, you'd update the UI to show the selected version
|
||||||
|
// For now, just highlight it
|
||||||
|
console.log('Selected regeneration:', messageId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (messageId: string) => {
|
||||||
|
const index = messages.value.findIndex(m => m.id === messageId);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
messages.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full w-full pb-4 justify-center">
|
||||||
|
<div class="max-w-4xl h-full w-full flex flex-col">
|
||||||
|
<div class="flex h-full flex-col gap-6 overflow-y-auto p-4">
|
||||||
|
<Message v-for="msg in messages" :key="msg.id" :message="msg" @regenerate="handleRegenerate"
|
||||||
|
@select="handleSelectRegeneration" @delete="handleDelete" />
|
||||||
|
|
||||||
|
<div v-if="generatingMessage" class="flex gap-3">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-8 h-8 rounded-lg bg-[var(--color-neutral)] border border-[var(--color-highlight)] flex items-center justify-center">
|
||||||
|
<Icon name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-[var(--color-neutral)]">Agent</p>
|
||||||
|
<p class="text-sm text-[var(--color-text)]">{{ generatingMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="messages.length === 0 && !generatingMessage" class="text-center text-[var(--color-subtle)]">
|
||||||
|
No messages yet. Start the conversation!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatInput @submit="handleSubmit" :loading="loading" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -10,17 +10,32 @@ if (session.value !== null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
|
||||||
});
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
let emailInputEl = ref<HTMLInputElement | null>(null);
|
let emailInputEl = ref<HTMLInputElement | null>(null);
|
||||||
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
let passwordInputEl = ref<HTMLInputElement | null>(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(() => {
|
onMounted(() => {
|
||||||
|
form.email = tempForm.email;
|
||||||
|
form.password = tempForm.password;
|
||||||
|
hydrated.value = true;
|
||||||
|
|
||||||
emailInputEl.value!.addEventListener("input", () => {
|
emailInputEl.value!.addEventListener("input", () => {
|
||||||
emailInputEl.value!.setCustomValidity("");
|
emailInputEl.value!.setCustomValidity("");
|
||||||
});
|
});
|
||||||
@@ -87,8 +102,8 @@ const submit = async () => {
|
|||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
||||||
autocomplete="current-password" id="password" v-model="form.password" />
|
autocomplete="current-password" id="password" v-model="form.password" />
|
||||||
<button class="accent" type="submit">
|
<button :disabled="!hydrated" class="accent" type="submit">
|
||||||
<iconify-icons v-if="loading" width="24" icon="svg-spinners:90-ring-with-bg" />
|
<Icon v-if="loading" width="24" name="svg-spinners:90-ring-with-bg" />
|
||||||
<span v-else>Login</span>
|
<span v-else>Login</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -28,7 +28,30 @@ let emailInputEl = ref<HTMLInputElement | null>(null);
|
|||||||
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||||
let confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
let confirmPasswordInputEl = ref<HTMLInputElement | null>(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(() => {
|
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!.addEventListener("input", () => {
|
||||||
nameInputEl.value!.setCustomValidity("");
|
nameInputEl.value!.setCustomValidity("");
|
||||||
});
|
});
|
||||||
@@ -123,7 +146,7 @@ const submit = async () => {
|
|||||||
<label for="confirmPassword">Confirm Password</label>
|
<label for="confirmPassword">Confirm Password</label>
|
||||||
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
|
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
|
||||||
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword" />
|
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword" />
|
||||||
<button class="accent" type="submit">
|
<button :disabled="!hydrated" class="accent" type="submit">
|
||||||
<Icon v-if="loading" class="text-6" name="svg-spinners:90-ring-with-bg" />
|
<Icon v-if="loading" class="text-6" name="svg-spinners:90-ring-with-bg" />
|
||||||
<span v-else>Register</span>
|
<span v-else>Register</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+3
-1
@@ -96,6 +96,8 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
|
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
|
||||||
<h1 class="text-center">{{ animatedText }}<span class="cursor"> </span></h1>
|
<h1 class="text-center">{{ animatedText }}<span class="cursor"> </span></h1>
|
||||||
<ChatInput @submit="handleChatSubmit" />
|
<div class="max-w-4xl h-full w-full">
|
||||||
|
<ChatInput @submit="handleChatSubmit" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface DropdownItem {
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
value?: string | number;
|
||||||
|
disabled?: boolean;
|
||||||
|
divider?: boolean;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.10",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"nuxt": "^4.2.2",
|
"nuxt": "^4.2.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"uuidv7": "^1.1.0",
|
"uuidv7": "^1.1.0",
|
||||||
@@ -1077,7 +1078,7 @@
|
|||||||
|
|
||||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
"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=="],
|
"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/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=="],
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||||
|
|||||||
+39
-21
@@ -1,43 +1,61 @@
|
|||||||
import { integer, pgTable, text, boolean, timestamp } from "drizzle-orm/pg-core";
|
import { integer, pgTable, text, boolean, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||||
import { uuidv7 } from "uuidv7";
|
import { nanoid } from "nanoid";
|
||||||
import { user } from "./auth/auth.schema";
|
import { user } from "./auth/auth.schema";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
export * from "./auth/auth.schema";
|
export * from "./auth/auth.schema";
|
||||||
|
|
||||||
export const agents = pgTable("agents", {
|
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(),
|
userId: text("user_id").references(() => user.id).notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
systemPrompt: text("system_prompt").notNull(),
|
systemPrompt: text("system_prompt"),
|
||||||
imageUrl: text("image_url")
|
imageUrl: text("image_url")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const topics = pgTable("topics", {
|
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(),
|
userId: text("user_id").references(() => user.id).notNull(),
|
||||||
agentId: text("agent_id").references(() => agents.id).notNull(),
|
agentId: text("agent_id").references(() => agents.id).notNull(),
|
||||||
name: text("name").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),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const messages = pgTable("messages", {
|
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(),
|
userId: text("user_id").references(() => user.id).notNull(),
|
||||||
topicId: text("topic_id").references(() => topics.id).notNull(),
|
topicId: text("topic_id").references(() => topics.id).notNull(),
|
||||||
isUser: boolean("is_user").notNull(),
|
|
||||||
content: text("content").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"),
|
model: text("model"),
|
||||||
tokensGenerated: integer("tokens_generated"),
|
tokensGenerated: integer("tokens_generated"),
|
||||||
tokensUsedThinking: integer("tokens_used_thinking"),
|
tokensUsedThinking: integer("tokens_used_thinking"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
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'
|
||||||
|
})
|
||||||
|
}));
|
||||||
@@ -3,6 +3,7 @@ services:
|
|||||||
postgresql:
|
postgresql:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
container_name: veridian-postgres
|
container_name: veridian-postgres
|
||||||
|
command: postgres -c wal_level=logical
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.10",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"nuxt": "^4.2.2",
|
"nuxt": "^4.2.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"uuidv7": "^1.1.0",
|
"uuidv7": "^1.1.0",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { agents } from "~~/db/schema";
|
import { agents } from "~~/db/schema";
|
||||||
import { protectRoute } from "~~/server/utils/auth";
|
import { protectRoute } from "~~/server/utils/auth";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
|
|
||||||
const db = useDrizzle();
|
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;
|
return rows;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { protectRoute } from '~~/server/utils/auth';
|
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';
|
import type { GenerateRequestBody } from '~~/server/types/chat';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
|
|
||||||
const body = await readBody(event) as GenerateRequestBody;
|
const body = await readBody(event) as GenerateRequestBody;
|
||||||
const { topicId, messages } = body;
|
const { topicId, messages, regeneratesFrom } = body;
|
||||||
|
|
||||||
if (!topicId || !messages) {
|
if (!topicId || !messages) {
|
||||||
throw createError({
|
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,
|
||||||
return {
|
status: 'pending',
|
||||||
generationId,
|
regeneratesFrom
|
||||||
status: 'pending'
|
};
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error('Failed to create generation:', error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to create generation'
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { protectRoute } from '~~/server/utils/auth';
|
import { protectRoute } from '~~/server/utils/auth';
|
||||||
import { getPendingGeneration, getActiveGeneration, isGenerationActive } from '~~/server/utils/generation';
|
import { getGenerationStatus } from '~~/server/utils/generation';
|
||||||
import type { GenerationStatus } from '~~/server/types/chat';
|
import type { GenerationStatusResponse } from '~~/server/types/chat';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
@@ -14,39 +14,30 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingGeneration = getPendingGeneration(generationId);
|
const generation = await getGenerationStatus(generationId);
|
||||||
const isActive = isGenerationActive(generationId);
|
|
||||||
const activeGeneration = getActiveGeneration(generationId);
|
|
||||||
|
|
||||||
if (!pendingGeneration && !activeGeneration) {
|
if (!generation) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: 'Generation not found'
|
statusMessage: 'Generation not found'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingGeneration) {
|
// Verify ownership
|
||||||
const status: GenerationStatus = {
|
if (generation.userId !== event.context.user.id) {
|
||||||
generationId,
|
throw createError({
|
||||||
status: 'pending',
|
statusCode: 403,
|
||||||
topicId: pendingGeneration.topicId
|
statusMessage: 'Unauthorized'
|
||||||
};
|
});
|
||||||
return status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive && activeGeneration) {
|
const status: GenerationStatusResponse = {
|
||||||
const status: GenerationStatus = {
|
|
||||||
generationId,
|
|
||||||
status: 'active',
|
|
||||||
content: activeGeneration.content,
|
|
||||||
topicId: activeGeneration.topicId
|
|
||||||
};
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status: GenerationStatus = {
|
|
||||||
generationId,
|
generationId,
|
||||||
status: 'completed'
|
status: generation.status as any,
|
||||||
|
topicId: generation.topicId,
|
||||||
|
content: generation.content,
|
||||||
|
error: generation.error || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { protectRoute } from '~~/server/utils/auth';
|
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 { eventHandler, setHeader, setResponseStatus } from 'h3';
|
||||||
import { generations, messages } from '~~/db/schema';
|
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { useDrizzle } from '~~/server/utils/drizzle';
|
||||||
|
import { generations, messages } from '~~/db/schema';
|
||||||
|
|
||||||
export default eventHandler(async (event) => {
|
export default eventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
@@ -16,12 +17,20 @@ export default eventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingGeneration = getPendingGeneration(generationId);
|
// Fetch generation from database
|
||||||
|
const generation = await getGenerationStatus(generationId);
|
||||||
if (pendingGeneration && pendingGeneration.expired) {
|
if (!generation) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 410,
|
statusCode: 404,
|
||||||
statusMessage: 'Generation expired - no client connected within 60 seconds'
|
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);
|
setResponseStatus(event, 200);
|
||||||
|
|
||||||
const shouldStartGeneration = pendingGeneration;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
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) {
|
if (message) {
|
||||||
startGeneration(generationId, controller);
|
sendToClient(controller, {
|
||||||
} else {
|
type: 'complete',
|
||||||
const generation = await useDrizzle().select().from(generations).where(eq(generations.id, getRouterParam(event, 'id')!))
|
data: message
|
||||||
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' });
|
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, {
|
sendToClient(controller, {
|
||||||
type: 'complete',
|
type: 'error',
|
||||||
data: message[0].content
|
data: { error: 'Stream initialization failed' }
|
||||||
});
|
});
|
||||||
controller.close();
|
controller.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
event.node.req.on('close', () => {
|
|
||||||
removeClientFromGeneration(generationId, controller);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendStream(event, stream);
|
return sendStream(event, stream);
|
||||||
} catch (error) {
|
} 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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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 { messages, topics } from "~~/db/schema";
|
||||||
|
import { protectRoute } from "~~/server/utils/auth";
|
||||||
import type { Message, Topic } from '~~/types'
|
import type { Message, Topic } from '~~/types'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
|
|
||||||
const db = useDrizzle();
|
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) {
|
if (rows.length === 0) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -17,7 +30,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const topic = rows[0] as Topic & { messages: Message[] };
|
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;
|
return topic;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { topics } from "~~/db/schema";
|
import { topics } from "~~/db/schema";
|
||||||
import { protectRoute } from "~~/server/utils/auth";
|
import { protectRoute } from "~~/server/utils/auth";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
|
|
||||||
const db = useDrizzle();
|
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;
|
return rows;
|
||||||
});
|
});
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
import { topics } from "~~/db/schema";
|
import { topics, agents } from "~~/db/schema";
|
||||||
import { protectRoute } from "~~/server/utils/auth";
|
import { protectRoute } from "~~/server/utils/auth";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
|
|
||||||
const db = useDrizzle();
|
const db = useDrizzle();
|
||||||
|
const userId = event.context.user.id;
|
||||||
|
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { agentId, name } = body;
|
const { agentId, name } = body;
|
||||||
|
|
||||||
if (!agentId || !name) {
|
if (!agentId || !name) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' });
|
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;
|
return inserted;
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export type MessageType = 'system' | 'agent' | 'user';
|
export type MessageType = 'system' | 'agent' | 'user';
|
||||||
|
export type GenerationStatus = 'pending' | 'active' | 'completed' | 'failed';
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
type: MessageType;
|
type: MessageType;
|
||||||
@@ -8,19 +9,21 @@ export interface ChatMessage {
|
|||||||
export interface GenerateRequestBody {
|
export interface GenerateRequestBody {
|
||||||
topicId: string;
|
topicId: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
regeneratesFrom?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationStreamEvent {
|
export interface GenerationStreamEvent {
|
||||||
type: 'token' | 'complete' | 'error';
|
type: 'start' | 'token' | 'complete' | 'error';
|
||||||
data: string | object | null;
|
data: string | object | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationStatus {
|
export interface GenerationStatusResponse {
|
||||||
generationId: string;
|
generationId: string;
|
||||||
status: 'pending' | 'active' | 'completed' | 'error';
|
status: GenerationStatus;
|
||||||
content?: string;
|
content?: string;
|
||||||
topicId?: string;
|
topicId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
tokensGenerated?: number;
|
tokensGenerated?: number;
|
||||||
tokensUsedThinking?: number;
|
tokensUsedThinking?: number;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
+197
-122
@@ -1,70 +1,63 @@
|
|||||||
import { useDrizzle } from '~~/server/utils/drizzle';
|
import { useDrizzle } from '~~/server/utils/drizzle';
|
||||||
import { generations, messages as messages_drizzle } from '~~/db/schema';
|
import { generations, messages as messages_drizzle, messagesRelations } from '~~/db/schema';
|
||||||
import { type GenerationStreamEvent, type ChatMessage, type MessageType } from '~~/server/types/chat';
|
import { type GenerationStreamEvent, type ChatMessage, type GenerationStatus } from '~~/server/types/chat';
|
||||||
import { eq } from 'drizzle-orm';
|
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;
|
userId: string;
|
||||||
topicId: string;
|
topicId: string;
|
||||||
messages: ChatMessage[];
|
|
||||||
content: string;
|
|
||||||
clients: Set<ReadableStreamDefaultController<Uint8Array>>;
|
clients: Set<ReadableStreamDefaultController<Uint8Array>>;
|
||||||
complete: boolean;
|
isGenerating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingGeneration {
|
const activeGenerationStreams = new Map<string, ActiveGenerationStream>();
|
||||||
generationId: string;
|
const db = useDrizzle();
|
||||||
userId: string;
|
|
||||||
topicId: string;
|
|
||||||
messages: ChatMessage[];
|
|
||||||
timeout: NodeJS.Timeout;
|
|
||||||
expired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeGenerations = new Map<string, ActiveGeneration>();
|
|
||||||
const pendingGenerations = new Map<string, PendingGeneration>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a client connection to an active generation stream
|
||||||
|
*/
|
||||||
export const addClientToGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): boolean => {
|
export const addClientToGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): boolean => {
|
||||||
const generation = activeGenerations.get(generationId);
|
const stream = activeGenerationStreams.get(generationId);
|
||||||
if (!generation) {
|
if (!stream) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
generation.clients.add(controller);
|
stream.clients.add(controller);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a client connection from an active generation stream
|
||||||
|
*/
|
||||||
export const removeClientFromGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): void => {
|
export const removeClientFromGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): void => {
|
||||||
const generation = activeGenerations.get(generationId);
|
const stream = activeGenerationStreams.get(generationId);
|
||||||
if (generation) {
|
if (stream) {
|
||||||
generation.clients.delete(controller);
|
stream.clients.delete(controller);
|
||||||
if (generation.clients.size === 0 && generation.complete) {
|
// Clean up if no clients left and generation is complete
|
||||||
activeGenerations.delete(generationId);
|
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);
|
sendToClient(client, event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an event to a single client
|
||||||
|
*/
|
||||||
export const sendToClient = (client: ReadableStreamDefaultController<Uint8Array>, event: GenerationStreamEvent): void => {
|
export const sendToClient = (client: ReadableStreamDefaultController<Uint8Array>, event: GenerationStreamEvent): void => {
|
||||||
const data = JSON.stringify(event);
|
const data = JSON.stringify(event);
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
@@ -73,12 +66,15 @@ export const sendToClient = (client: ReadableStreamDefaultController<Uint8Array>
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send to client:', error);
|
console.error('Failed to send to client:', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a prompt from chat messages
|
||||||
|
*/
|
||||||
const buildPrompt = (messages: ChatMessage[]): string => {
|
const buildPrompt = (messages: ChatMessage[]): string => {
|
||||||
return messages
|
return messages
|
||||||
.map((msg: ChatMessage) => {
|
.map((msg: ChatMessage) => {
|
||||||
const roleMap: Record<MessageType, string> = {
|
const roleMap: Record<typeof msg.type, string> = {
|
||||||
system: 'System',
|
system: 'System',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
agent: 'Assistant'
|
agent: 'Assistant'
|
||||||
@@ -88,121 +84,200 @@ const buildPrompt = (messages: ChatMessage[]): string => {
|
|||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = useDrizzle();
|
/**
|
||||||
|
* Create a new pending generation in the database
|
||||||
export const startGeneration = async (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): Promise<void> => {
|
* Returns the generation ID
|
||||||
const pending = pendingGenerations.get(generationId);
|
*/
|
||||||
|
export const createPendingGeneration = async (userId: string, topicId: string, messages: ChatMessage[], regeneratesFrom?: string): Promise<string> => {
|
||||||
if (!pending) {
|
const generationValues: any = {
|
||||||
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 = {
|
|
||||||
userId,
|
userId,
|
||||||
topicId,
|
topicId,
|
||||||
messages,
|
status: 'pending' as GenerationStatus,
|
||||||
content: '',
|
|
||||||
clients: new Set([controller]),
|
|
||||||
complete: false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(generations).values({
|
if (regeneratesFrom) {
|
||||||
id: generationId,
|
generationValues.regeneratesFrom = regeneratesFrom;
|
||||||
userId,
|
}
|
||||||
topicId,
|
|
||||||
messageId: null
|
|
||||||
});
|
|
||||||
|
|
||||||
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<Uint8Array>
|
||||||
|
): Promise<void> => {
|
||||||
try {
|
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 dummyResponse = generateDummyResponse(prompt, messages);
|
||||||
const tokens = dummyResponse.split(' ');
|
const tokens = dummyResponse.split(' ');
|
||||||
|
|
||||||
|
// Simulate token streaming
|
||||||
|
let accumulatedContent = '';
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
generation.content += token + ' ';
|
accumulatedContent += token + ' ';
|
||||||
|
|
||||||
sendToClients(generation, {
|
sendToClients(generationId, {
|
||||||
type: 'token',
|
type: 'token',
|
||||||
data: token + ' '
|
data: token + ' ',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [message] = await db.insert(messages_drizzle).values({
|
const finalContent = accumulatedContent.trim();
|
||||||
topicId: generation.topicId,
|
|
||||||
userId: generation.userId,
|
// Create message record for this generation
|
||||||
|
const messageValues: any = {
|
||||||
|
topicId,
|
||||||
|
userId,
|
||||||
|
content: finalContent,
|
||||||
isUser: false,
|
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',
|
type: 'complete',
|
||||||
data: message
|
data: fmessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (generation.clients.size === 0) {
|
// Close all client connections
|
||||||
activeGenerations.delete(generationId);
|
const finalStream = activeGenerationStreams.get(generationId);
|
||||||
} else {
|
if (finalStream) {
|
||||||
generation.clients.forEach((client) => {
|
for (const client of finalStream.clients) {
|
||||||
try {
|
try {
|
||||||
client.close();
|
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) {
|
} catch (error) {
|
||||||
console.error('Generation failed:', 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',
|
type: 'error',
|
||||||
data: error instanceof Error ? error.message : 'Unknown error'
|
data: { error: errorMessage },
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.delete(generations).where(eq(generations.id, generationId));
|
// Close all client connections
|
||||||
|
const stream = activeGenerationStreams.get(generationId);
|
||||||
activeGenerations.delete(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(() => {
|
* Check if a generation is currently being streamed
|
||||||
const gen = pendingGenerations.get(generationId)
|
*/
|
||||||
if (gen) gen.expired = true;
|
export const isGenerationStreaming = (generationId: string): boolean => {
|
||||||
console.log(`Generation ${generationId} expired - no client connected within 60 seconds`);
|
return activeGenerationStreams.has(generationId);
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
pendingGenerations.set(generationId, {
|
|
||||||
generationId,
|
|
||||||
userId,
|
|
||||||
topicId,
|
|
||||||
messages,
|
|
||||||
timeout,
|
|
||||||
expired: false
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Registered pending generation ${generationId}, waiting for client connection...`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a dummy response for testing
|
||||||
|
*/
|
||||||
const generateDummyResponse = (prompt: string, messages: ChatMessage[]): string => {
|
const generateDummyResponse = (prompt: string, messages: ChatMessage[]): string => {
|
||||||
const responses = [
|
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."
|
"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)];
|
return responses[Math.floor(Math.random() * responses.length)];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user