♻️ refactor: optimize state management, switch to @tanstack/vue-virtual, and improve performance

- Centralize `useAgents` and `useModels` state within the Nuxt app context to prevent data leaks and improve initialization.
- Migrate virtualization from `vue-virtual-scroller` to `@tanstack/vue-virtual` with new `RowVirtualizerFixed` and `RowVirtualizerDynamic` components.
- Upgrade Nuxt to v4.3.1 and remove `@vue-macros/nuxt`.
- Replace `big.js` with an optimized custom `lshDecimal` string manipulation logic for pricing calculations in the provider API.
- Implement automatic focus redirection in `ChatInput` to capture standard keyboard input.
- Refactor Sidenav and Settings components to utilize virtualization for long lists (topics, agents, models).
- Enhance theme colors and mobile experience. More work to come on both of these.
This commit is contained in:
Zoe
2026-02-23 15:56:10 +00:00
parent 59bb7fbc12
commit 6ee4087a29
42 changed files with 889 additions and 828 deletions
+19
View File
@@ -0,0 +1,19 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Run vite",
"runtimeExecutable": "npm",
"cwd": "${workspaceFolder}",
"args": [
"run",
"dev"
]
},
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"dependi.npm.lockFileEnabled": true
}
-2
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
// import 'katex/dist/katex.min.css'
import '~/assets/css/reset.css';
import '~/assets/css/base.css';
+14 -19
View File
@@ -57,15 +57,15 @@
}
:root.dark {
--palette-zinc-100: #09080A;
--palette-zinc-200: #1a191b;
--palette-zinc-300: #2e2d2f;
--palette-zinc-100: #09080D;
--palette-zinc-200: #1a191E;
--palette-zinc-300: #2e2d32;
--palette-slate-100: #0f171c;
--palette-slate-200: #1e252b;
--palette-slate-300: #333b44;
--palette-obsidian-100: #050504;
--palette-obsidian-200: #0f0f0e;
--palette-obsidian-300: #1e1f1f;
--palette-obsidian-100: #050307;
--palette-obsidian-200: #0f0e11;
--palette-obsidian-300: #1e1d20;
--text-primary: color-mix(in srgb, #fafafa, var(--color-accent) var(--accent-hinting));
/* Selected topics */
@@ -177,6 +177,8 @@ html.light {
html {
height: 100vh;
/* something something progressive enhancement */
height: 100dvh;
overflow: hidden;
}
@@ -270,6 +272,11 @@ button.accent:hover {
transform: translateX(calc(2.5em - 1.3em - 0.5rem));
}
:where(.i-tabler\:dots) {
background-color: currentColor;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0'/%3E%3C/svg%3E");
}
.reasoning-contaizner.middle {
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
}
@@ -465,16 +472,4 @@ article .checkbox {
width: min-content;
}
/* end markdown renderer */
.reasoning-contaizner.middle {
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.top {
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.bottom {
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
}
/* end markdown renderer */
+17 -4
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Entity } from '@triplit/client';
import { schema } from '#triplit/schema';
import type { Agent } from '~/composables/useAgents';
const { allModels } = await useModels();
const { allModels } = useModels();
const inputHeight: Ref<string> = ref('auto');
const inputRef = ref<HTMLTextAreaElement | null>(null);
@@ -19,7 +18,7 @@ const emit = defineEmits<{
const props = defineProps<{
loading?: boolean;
agent?: Entity<typeof schema, 'agents'>;
agent: Agent | null;
providers?: ProviderWithModels[];
}>();
@@ -107,6 +106,15 @@ const handleKeyDown = async (event: KeyboardEvent) => {
}
};
const handleWindowKeyDown = async (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey) {
return;
}
// redirect all standard keyboard input to the input field
inputRef.value?.focus();
};
watch(inputValue, async () => {
const textarea = inputRef.value;
if (!textarea) return;
@@ -141,6 +149,11 @@ onBeforeMount(() => {
onMounted(() => {
inputValue.value = tempInput;
document.addEventListener('keydown', handleWindowKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleWindowKeyDown);
});
</script>
+6 -2
View File
@@ -41,11 +41,15 @@ const renderNode = (node: any, index: number): any => {
return null;
};
defineRender(() => {
const render = () => {
const children = ast.value?.children?.flatMap(renderNode) || [];
return h('div', { class: 'prose-wrapper' }, [
h('article', { class: 'markdown-body' }, children)
]);
})
}
</script>
<template>
<render />
</template>
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import { type Grammar } from 'shiki';
import { hashSync } from '~/utils/hash';
const props = defineProps<{ code: string; language: string }>();
+5 -5
View File
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
@@ -56,13 +56,13 @@ const toggleReasoning = async () => {
'w-full hover:bg-[var(--color-hover)] rounded-lg p-1 flex justify-between items-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
part.finished ? '' : 'cursor-default'
]">
<div class="flex items-center gap-1">
<div
<span class="flex items-center gap-1">
<span
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
</div>
</span>
Deep Thinking
</div>
</span>
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
</button>
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import Debug from './Debug.vue';
+2 -3
View File
@@ -11,7 +11,6 @@ defineProps<{
</script>
<template>
<!-- <pre class="max-w-full overflow-x-auto">{{ JSON.stringify(message, null, 2) }}</pre> -->
<div class="flex flex-col w-full gap-2">
<div v-for="part in message.parts" :key="part.id">
<Reasoning v-if="part.type === 'reasoning'" :part="part" />
@@ -36,10 +35,10 @@ defineProps<{
<div class="flex flex-row justify-between text-[var(--text-tertiary)] text-xs"
v-if="message.generation && message.generation.status !== 'pending'">
<span class="flex items-center gap-1">
<div class="flex items-center gap-1">
<ModelIcon :size="12" :model-id="message.generation.modelId" />
{{ message.generation.modelId }}
</span>
</div>
<div class="flex gap-2">
<span class="flex gap-1 items-center" v-if="message.generation.tokens?.output">
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
+1 -1
View File
@@ -93,7 +93,7 @@ const deleteModel = async () => {
<span v-if="model.cost.request && model.cost.request !== '0'"
class="text-xs whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
{{ model.cost.request }}/request
${{ model.cost.request }}/K request
</span>
</template>
</div>
+3 -3
View File
@@ -192,8 +192,8 @@ useClickOutside(dropdownRef, closeDropdown);
<template>
<div ref="dropdownRef" class="relative">
<button @click="isOpen = !isOpen" ref="dropdownButton"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
<div role="button" @click="isOpen = !isOpen" ref="dropdownButton"
class="cursor-pointer flex items-center w-fit select-none gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
:class="[
isOpen
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
@@ -206,7 +206,7 @@ useClickOutside(dropdownRef, closeDropdown);
</span>
<Icon name="mynaui:chevron-down" class="text-3.5 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }" />
</button>
</div>
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual';
const props = defineProps<{
items: any[];
keyField?: string;
scrollElement: HTMLElement | null;
minItemSize: number;
overscan: number;
prerender?: number;
}>();
const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.items.length,
getScrollElement: () => props.scrollElement,
estimateSize: () => props.minItemSize,
overscan: props.overscan,
getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index,
initialRect: {
width: 0,
height: props.prerender ? props.minItemSize * props.prerender : 0
},
})));
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
const measureElement = (el: Element) => {
if (!el) {
return
}
rowVirtualizer.value.measureElement(el)
return undefined
}
watch(() => props.items, () => {
rowVirtualizer.value.measure();
}, { deep: false });
</script>
<template>
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }">
<div v-for="virtualRow in virtualRows" :ref="(el) => measureElement(el as Element)"
:key="(virtualRow.key as any | number)" :data-index="virtualRow.index" :style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}`,
transform: `translateY(${virtualRow.start}px)`,
}">
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
</div>
</div>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual';
const props = defineProps<{
items: any[];
keyField?: string;
scrollElement: HTMLElement | null;
itemSize: number;
overscan: number;
prerender?: number;
}>();
const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.items.length,
getScrollElement: () => props.scrollElement,
estimateSize: () => props.itemSize,
overscan: props.overscan,
getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index,
initialRect: {
width: 0,
height: props.prerender ? props.itemSize * props.prerender : 0
},
})));
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
watch(() => props.items, () => {
rowVirtualizer.value.measure();
}, { deep: false });
</script>
<template>
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }">
<div v-for="virtualRow in virtualRows" :key="(virtualRow.key as any | number)" :style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}`,
transform: `translateY(${virtualRow.start}px)`,
}">
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
</div>
</div>
</template>
+33 -39
View File
@@ -4,12 +4,13 @@ import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/
import { providerBaseUrls, type Model } from '~/types/model';
import { useSettings } from '~/composables/useSettings';
import ModelItem from './ModelItem.vue';
// @ts-ignore
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
const triplit = useTriplitClient();
const { pageParams } = useSettings();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
const { providers } = useModels();
const scrollContainerRef = ref<HTMLDivElement | null>(null);
const provider = computed(() => {
if (pageParams.value.length === 0) return null;
@@ -29,9 +30,16 @@ const modelSearch = ref('');
watch(pageParams, () => {
if (pageParams.value.length === 0) return;
console.log(scrollContainerRef.value);
apiKey.value = provider.value?.config.apiKey ?? '';
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
modelSearch.value = '';
nextTick(() => {
if (scrollContainerRef.value) {
scrollContainerRef.value.scrollTo({ top: 0, behavior: 'instant' });
}
});
})
const decryptApiKey = async () => {
@@ -142,7 +150,8 @@ const fetchModels = async () => {
}
// delete models that are not in the API response and are not custom models
const apiModelIds = new Set(existingModelsMap.values().map((m: any) => m.externalId));
const apiModelIds = new Set(modelsData.models.values().map((m: any) => m.id));
console.log("apiModelIds", apiModelIds);
const toDelete = (provider.value?.models || []).filter((m: any) =>
!m.isCustom && !apiModelIds.has(m.externalId)
);
@@ -177,15 +186,13 @@ const disabledModels = computed(() =>
.sort(sortByReleaseDate)
)
onUnmounted(() => {
unsubscribeModels?.();
});
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-4 mt-4" v-if="provider">
<div ref="scrollContainerRef"
class="flex flex-col gap-4 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]"
v-if="provider">
<div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">Enabled</label>
<Slider :checked="provider.enabled" @click.stop="toggleProvider()" />
@@ -208,7 +215,7 @@ defineEmits(['navigate']);
<div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
<input :placeholder="providerBaseUrls[provider!.type] ?? ''"
<input :placeholder="provider.type ? providerBaseUrls[provider.type] ?? '' : ''"
class="placeholder:text-[var(--text-tertiary)] w-full px-2 py-1 bg-transparent" type="text"
id="provider-proxy-url" :value="apiProxyUrl"
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
@@ -266,37 +273,24 @@ defineEmits(['navigate']);
Enabled
</span>
<div class="flex flex-col gap-1">
<DynamicScroller class="scroller" page-mode :min-item-size="64" :buffer="640"
:items="enabledModels" key-field="id">
<template v-slot="{ item: model, index, active }">
<DynamicScrollerItem :item="model" :active="active" :size-dependencies="[
model.name,
model.externalId,
modelSearch
]" :data-index="index">
<ModelItem :model="model" />
</DynamicScrollerItem>
<RowVirtualizerDynamic :items="enabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20">
<template v-slot="{ item: model }">
<ModelItem :model="model" />
</template>
</DynamicScroller>
</RowVirtualizerDynamic>
</div>
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
Disabled
</span>
<div class="flex flex-col gap-1">
<DynamicScroller class="scroller" page-mode :min-item-size="64" :buffer="640"
:items="disabledModels" key-field="id">
<template v-slot="{ item: model, index, active }">
<DynamicScrollerItem :item="model" :active="active" :size-dependencies="[
model.name,
model.externalId,
model.cost,
modelSearch
]" :data-index="index">
<ModelItem :model="model" />
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div>
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
Disabled
</span>
<div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="disabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20">
<template v-slot="{ item: model }">
<ModelItem :model="model" />
</template>
</RowVirtualizerDynamic>
</div>
</div>
</ClientOnly>
@@ -22,7 +22,8 @@ defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-6 mt-4">
<div
class="flex flex-col gap-6 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-row items-center justify-between gap-2">
<h4 class="font-medium">Theme</h4>
<div class="flex gap-2">
+3 -4
View File
@@ -6,7 +6,7 @@ import SystemAssistants from './SystemAssistants.vue';
import AppearanceSettings from './AppearanceSettings.vue';
import AIServiceProvider from './AIServiceProvider.vue';
const { providers, unsubscribe: unsubscribeModels } = await useModels();
const { providers } = useModels();
const PAGES_CONFIG = {
general: {
@@ -81,7 +81,6 @@ onUnmounted(() => {
if (open.value) {
document.body.removeEventListener('keydown', handleKeyDown);
}
unsubscribeModels?.();
});
</script>
@@ -114,8 +113,8 @@ onUnmounted(() => {
<!-- DYNAMIC CONTENT -->
<main class="flex-1 flex flex-col overflow-hidden">
<div
class="flex flex-col flex-1 p-3 bg-[var(--bg-surface)] overflow-y-auto border rounded-lg border-[var(--color-border)]">
<header class="flex items-center justify-between pl-2 pb-2 ">
class="max-h-full h-full flex flex-col flex-1 px-3 bg-[var(--bg-surface)] border rounded-lg border-[var(--color-border)]">
<header class="flex items-center justify-between pl-2 pb-2 pt-2 ">
<h2 class="text-lg font-semibold m-0 capitalize">{{ runtimePage.label }}</h2>
<button
class="hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
+2 -4
View File
@@ -1,7 +1,5 @@
<script lang="ts" setup>
const props = defineProps<{
model: ModelWithProvider;
}>();
<script setup lang="ts">
const props = defineProps<{ model: ModelWithProvider; }>();
</script>
<template>
+3 -6
View File
@@ -3,7 +3,7 @@ import { Providers } from '~/types/model';
import { providerIcons } from '~/utils/model-mapping';
const triplit = useTriplitClient();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
const { providers } = useModels();
if (providers.value === undefined) throw new Error('Providers not loaded');
@@ -37,15 +37,12 @@ const toggleProvider = async (id: string) => {
});
};
onUnmounted(() => {
unsubscribeModels?.();
});
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-1">
<div
class="flex flex-col gap-1 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<h2 class="text-lg font-semibold flex items-center gap-2">
Enabled <span class="text-sm bg-[var(--bg-container)] px-2 rounded-md py-0.5 text-[var(--text-secondary)]">
{{providers?.filter(p => p.enabled).length}}
+2 -6
View File
@@ -2,17 +2,13 @@
import { providerIcons } from '~/utils/model-mapping';
const { pageParams } = useSettings();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
onUnmounted(() => {
unsubscribeModels?.();
});
const { providers } = await useModels();
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-1 overflow-auto">
<button @click="$emit('navigate', 'general')"
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Icon name="mynaui:chevron-left" class="text-4" /> Back to General
+2 -1
View File
@@ -39,7 +39,8 @@ defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-4 flex-grow">
<div
class="flex flex-col gap-4 py-4 flex-grow overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key">
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
<h4 class="capitalize">{{ key }}</h4>
+3 -38
View File
@@ -1,15 +1,11 @@
<script setup lang="ts">
const route = useRoute();
const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { agents, getAgent } = useAgents();
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const activeAgent = computed(() => getAgent(route.params.id as string));
const activeAgent = getAgent(route.params.id as string);
const { isHovered, sidebarWidth } = useSidenavContext();
onUnmounted(() => {
unsubscribeAgents?.();
});
const { isHovered } = useSidenavContext();
const toggleDropdown = (e: MouseEvent) => {
e.stopPropagation();
@@ -66,36 +62,5 @@ const toggleDropdown = (e: MouseEvent) => {
</div>
</div>
</button>
<!-- <Dropdown class="overflow-hidden" v-model="agentDropdownOpen" placement="center" width="calc(80% - 1rem)">
<template #trigger="{ toggle }">
<button
class="flex max-w-full transition duration-200 gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"
@click="toggle">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-border)]']">
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
class="w-full h-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ activeAgent?.name }}
</span>
<div class="w-4 h-4 text-[var(--text-secondary)]">
<Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
name="mynaui:chevron-up-down" />
</div>
</button>
</template>
<template #content>
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
:active="activeAgent?.id === agent.id" />
</div>
</template>
</Dropdown> -->
</header>
</template>
+51 -75
View File
@@ -1,18 +1,24 @@
<script setup lang="ts">
import { assert } from '~~/utils/assert';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
const route = useRoute();
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { getAgent } = useAgents();
const triplit = useTriplitClient();
const activeAgent = computed(() => getAgent(route.params.id as string));
const navRef = ref<HTMLElement | null>(null);
const activeAgent = getAgent(route.params.id as string);
watch(() => route.params.id, () => {
if (navRef.value) {
navRef.value.scrollTo({ top: 0, behavior: 'instant' });
}
}, { immediate: true });
const topics = computed(() => {
if (activeAgent.value === undefined) return [];
return activeAgent.value.topics;
});
return activeAgent.value?.topics || [];
})
const topicsOpen = ref(true);
let activeAutoRenames = reactive(new Map<string, string>());
@@ -116,54 +122,33 @@ const handleNavClick = (e: MouseEvent) => {
if (!topicId) return;
const action = trigger.dataset.action;
if (action === 'navigate') {
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
switch (action) {
case 'navigate':
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
e.preventDefault();
return navigateTo(`/agent/${route.params.id}/topic/${topicId}`);
e.preventDefault();
return navigateTo(`/agent/${route.params.id}/topic/${topicId}`);
}
if (action === 'toggle-dropdown') {
e.preventDefault();
e.stopPropagation();
if (dropdownState.open) {
closeDropdown();
return;
}
const itemsFactory = () => {
const items = [];
const isRenaming = activeAutoRenames.has(topicId) && topics.value?.find(t => t.id === topicId)?.renaming;
if (isRenaming) {
items.push({
label: 'Cancel Auto Rename',
onClick: () => cancelAutoRename(topicId)
});
} else {
items.push({
label: 'Auto Rename',
onClick: () => autoRenameTopic(topicId)
});
case 'toggle-dropdown':
e.preventDefault();
e.stopPropagation();
if (dropdownState.open) {
closeDropdown();
return;
}
items.push({
label: 'Rename',
disabled: isRenaming ?? false,
onClick: () => startRename(topicId, topics.value?.find(t => t.id === topicId)?.name || '')
});
openDropdown(e, () => {
const topic = topics.value.find(t => t.id === topicId);
const isRenaming = activeAutoRenames.has(topicId) && topic?.renaming;
items.push({
label: 'Delete',
danger: true,
onClick: () => deleteTopic(topicId)
});
return items;
};
openDropdown(e, itemsFactory, { minWidth: '120px', placement: 'right' });
return [
isRenaming
? { label: 'Cancel Auto Rename', onClick: () => cancelAutoRename(topicId) }
: { label: 'Auto Rename', onClick: () => autoRenameTopic(topicId) },
{ label: 'Rename', disabled: isRenaming ?? false, onClick: () => startRename(topicId, topic?.name || '') },
{ label: 'Delete', danger: true, onClick: () => deleteTopic(topicId) }
];
}, { minWidth: '120px', placement: 'right' });
break;
}
}
@@ -171,14 +156,11 @@ onMounted(() => {
// simply preload the topic page
preloadRouteComponents(`/agent/${route.params.id}/topic/42`);
})
onUnmounted(() => {
unsubscribeAgents?.();
});
</script>
<template>
<nav class="flex flex-col gap-1">
<nav ref="navRef"
class="max-h-full h-full overflow-auto [scrollbar-color:#888_transparent] [scrollbar-width:thin] [scrollbar-gutter:stable]">
<!-- Agent Info Link -->
<div class="mt-2 flex flex-col">
@@ -193,18 +175,14 @@ onUnmounted(() => {
</button>
<Collapsible :is-open="topicsOpen">
<div @click="handleNavClick"
class="mt-1 gap-1 flex flex-col transform-origin-center-top [content-visibility:auto] [contain-intrinsic-size:0_36px]">
<a v-for="topic in topics" :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
:href="`/agent/${route.params.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
'group relative decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
'px-2',
topic.id === route.params.topicId
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div class="flex justify-between items-center w-full">
<div @click="handleNavClick" class="mt-1 flex flex-col transform-origin-center-top">
<RowVirtualizerFixed :scroll-element="navRef" key-field="id" :prerender="50" :items="topics"
:item-size="40" :overscan="20">
<template v-slot="{ item: topic }">
<a :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
:href="`/agent/${route.params.id}/topic/${topic.id}`" :aria-label="topic.name"
class="mt-1 group px-2 decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9 text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] focus:text-[var(--text-primary)]"
:class="{ 'bg-[var(--color-hover)]': route.params.topicId === topic.id }">
<input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
@blur="saveRename"
@@ -218,18 +196,16 @@ onUnmounted(() => {
</span>
<div data-action="toggle-dropdown"
class="shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<svg class="pointer-events-none" xmlns="http://www.w3.org/2000/svg" width="18"
height="18" viewBox="0 0 24 24">
<path fill="currentColor"
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
</svg>
class="text-[var(--text-secondary)] shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="h-4.5 w-4.5 i-tabler:dots"></span>
</div>
</div>
</div>
</a>
</a>
</template>
</RowVirtualizerFixed>
</div>
</Collapsible>
</div>
</nav>
</template>
<!-- text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] -->
+78 -6
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
const { agents, unsubscribe, createAgent } = await useAgents();
const { agents, createAgent } = useAgents();
const agentsOpen = ref(true);
const creatingAgent = ref(false);
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const navRef = ref<HTMLElement | null>(null);
const toggleAgentsList = () => {
agentsOpen.value = !agentsOpen.value;
};
@@ -11,17 +14,62 @@ const newAgent = async () => {
creatingAgent.value = true;
try {
const agent = await createAgent();
if (agent) return navigateTo(`/agent/${agent.id}`);
return navigateTo(`/agent/${agent.id}`);
} finally {
creatingAgent.value = false;
}
};
onUnmounted(() => unsubscribe?.());
const handleNavClick = (e: MouseEvent) => {
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!trigger) return;
const agentId = trigger.dataset.agentId || (trigger.closest('[data-agent-id]') as HTMLElement | null)?.dataset.agentId;
if (!agentId) return;
const action = trigger.dataset.action;
switch (action) {
case 'navigate':
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
e.preventDefault();
return navigateTo(`/agent/${agentId}`);
case 'toggle-dropdown':
e.preventDefault();
e.stopPropagation();
if (dropdownState.open) {
closeDropdown();
return;
}
// openDropdown(e, () => {
// const agent = agents.value.find(a => a.id === agentId);
// if (agent) {
// const isRenaming = activeAutoRenames.has(agentId) && agent?.renaming;
// return [
// isRenaming
// ? { label: 'Cancel Auto Rename', onClick: () => cancelAutoRename(agentId) }
// : { label: 'Auto Rename', onClick: () => autoRenameAgent(agentId) },
// { label: 'Rename', disabled: isRenaming ?? false, onClick: () => startRename(agentId, agent?.name || '') },
// { label: 'Delete', danger: true, onClick: () => deleteAgent(agentId) }
// ];
// }
// return [];
// }, { minWidth: '120px', placement: 'right' });
break;
}
}
onMounted(() => {
// simply preload the topic page
preloadRouteComponents(`/agent/42`);
})
</script>
<template>
<nav class="flex flex-col gap-1">
<nav ref="navRef" class="flex flex-col gap-1 overflow-auto">
<SidenavItem name="Search" icon="mynaui:search" />
<SidenavItem to="/" name="Home" icon="mynaui:home" />
@@ -46,8 +94,32 @@ onUnmounted(() => unsubscribe?.());
<span>New Agent</span>
</button>
<SidenavItem v-for="agent in agents" :key="agent.id" :to="`/agent/${agent.id}`" :name="agent.name"
icon="mynaui:check-hexagon" />
<div @click="handleNavClick" class="flex flex-col">
<RowVirtualizerFixed :scroll-element="navRef" key-field="id" :items="agents" :prerender="50"
:item-size="40" :overscan="20">
<template v-slot="{ item: agent }">
<a data-action="navigate" :data-agent-id="agent.id" :href="`/agent/${agent.id}`"
:aria-label="agent.name"
class="mt-1 group px-2 decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9 text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] focus:text-[var(--text-primary)]">
<!-- <input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
@blur="saveRename"
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
<div v-else-if="topic.renaming" class="flex w-full">
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
</div> -->
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ agent.name }}
</span>
<div data-action="toggle-dropdown"
class="text-[var(--text-secondary)] shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="h-4.5 w-4.5 i-tabler:dots"></span>
</div>
</a>
</template>
</RowVirtualizerFixed>
</div>
</div>
</Collapsible>
</nav>
+3 -6
View File
@@ -109,7 +109,7 @@ const navKind = computed(() => {
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
<div class="flex flex-col h-full max-h-full overflow-y-hidden">
<!-- Header -->
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5 h-8">
<SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
@@ -135,11 +135,8 @@ const navKind = computed(() => {
</div>
<!-- Main Menu -->
<div
class="max-h-full h-full overflow-auto [scrollbar-color:#888_transparent] [scrollbar-width:thin] [scrollbar-gutter:stable]">
<SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" />
</div>
<SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" />
</div>
<div class="flex justify-between pt-2">
+54 -23
View File
@@ -1,26 +1,55 @@
import type schema from "#triplit/schema";
import type { Entity } from "@triplit/client";
import type schema from "#triplit/schema";
import { nanoid } from "nanoid";
import { assert } from "~~/utils/assert";
export const useAgents = async () => {
export type Agent = Readonly<Entity<typeof schema, 'agents'> & { topics: Readonly<Entity<typeof schema, 'topics'>>[] }>;
export const useAgents = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
const { results: agents, unsubscribe } = await useQuery(
'agents',
triplit,
triplit
.query('agents')
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
.Order('createdAt', 'ASC')
);
// dont leaking between different users/requests
if (!nuxtApp._agentsState) {
nuxtApp._agentsState = {
list: ref<Agent[]>([]),
initPromise: null as Promise<void> | null,
};
}
const createAgent = async (): Promise<Readonly<Entity<typeof schema, 'agents'>> | null> => {
const state = nuxtApp._agentsState as {
list: Ref<Agent[]>;
initPromise: Promise<void> | null;
};
const init = () => {
if (state.initPromise) return;
state.initPromise = (async () => {
const query = triplit.query('agents')
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
.Order('createdAt', 'ASC');
const { results } = await useQuery('agents', triplit, query)
watch(results, (newAgents) => {
console.log("newAgents", newAgents);
state.list.value = newAgents as unknown as Agent[] || [];
}, { immediate: true, flush: 'sync' });
})();
};
const getAgent = (id: MaybeRef<string>) => {
return computed(() => state.list.value.find((agent) => agent.id === id) || null);
}
const createAgent = async () => {
const triplit = useTriplitClient();
const { user } = useAuth();
if (!user.value) {
console.error('No user');
return null;
}
if (!user.value) throw new Error('No user');
return triplit.insert('agents', {
const id = nanoid();
await triplit.insert('agents', {
id,
name: 'New Agent',
userId: user.value.id,
systemPrompt: 'You are a helpful assistant.',
@@ -28,14 +57,16 @@ export const useAgents = async () => {
imageUrl: null,
createdAt: new Date().toISOString(),
});
};
assert('flush' in triplit);
await triplit.flush();
return state.list.value.find((agent) => agent.id === id)!;
};
return {
agents,
unsubscribe,
getAgent: (id: string) => {
return agents.value?.find((a: any) => a.id === id);
},
createAgent,
init,
agents: state.list,
getAgent,
createAgent
};
};
+59 -49
View File
@@ -1,68 +1,78 @@
import { schema } from '#triplit/schema';
// // composables/useModels.ts
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
export type ModelWithProvider = Entity<typeof schema, 'models'> & {
provider: Entity<typeof schema, 'providers'>;
};
type Provider = Entity<typeof schema, 'providers'>;
type Model = Entity<typeof schema, 'models'>;
export type ProviderWithModels = Entity<typeof schema, 'providers'> & {
models: Entity<typeof schema, 'models'>[];
};
export interface ModelWithProvider extends Model {
provider: Provider;
}
export const useModels = async () => {
const nuxtApp = useNuxtApp() as any;
export interface ProviderWithModels extends Provider {
models: Model[];
}
if (!nuxtApp._modelsSubscription) {
if (!nuxtApp._modelsPromise) {
const triplit = useTriplitClient();
export const useModels = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
const providersQuery = triplit
.query('providers')
.Include('models');
const start = Date.now();
nuxtApp._modelsPromise = useQuery('providers', triplit, providersQuery).then((sub) => {
nuxtApp._modelsSubscription = sub;
console.log("fetching providers took", Date.now() - start);
return sub;
});
}
await nuxtApp._modelsPromise;
if (!nuxtApp._modelsState) {
nuxtApp._modelsState = {
providers: shallowRef([]),
isReady: ref(false)
};
}
const { results: providers } = nuxtApp._modelsSubscription;
if (!nuxtApp._allModels) {
nuxtApp._allModels = computed<ModelWithProvider[]>(() => {
if (!providers.value) return [];
const state = nuxtApp._modelsState as {
providers: Ref<ProviderWithModels[]>;
initPromise: Promise<void> | null;
};
const list: ModelWithProvider[] = [];
const init = () => {
if (state.initPromise) return;
for (const provider of (providers.value as ProviderWithModels[])) {
if (!provider.enabled) continue;
state.initPromise = (async () => {
const query = triplit.query('providers').Include('models');
for (const model of provider.models) {
if (!model.enabled) continue;
const { results } = await useQuery('providers', triplit, query)
watch(results, (newProviders) => {
state.providers.value = newProviders as unknown as ProviderWithModels[] || [];
}, { immediate: true, flush: 'sync' });
})();
};
list.push({
...model,
provider
} as unknown as ModelWithProvider);
}
const allModels = computed<ModelWithProvider[]>(() => {
const result: ModelWithProvider[] = [];
for (const provider of state.providers.value) {
if (!provider.enabled) continue;
for (const model of provider.models || []) {
if (!model.enabled) continue;
result.push({ ...model, provider });
}
return list;
});
}
}
return result;
});
const getFirstAvailableModel = (): ModelWithProvider | null => {
if (nuxtApp._allModels.value.length === 0) return null;
return nuxtApp._allModels.value[0]!;
const getModel = (id: string): ModelWithProvider | undefined => {
return allModels.value.find((model) => model.id === id);
};
const getProvider = (id: string): ProviderWithModels | undefined => {
return state.providers.value.find((provider) => provider.id === id);
};
const getFirstAvailableModel = () => {
return allModels.value[0] ?? null;
};
return {
providers: providers as Ref<ProviderWithModels[]>,
allModels: nuxtApp._allModels as ComputedRef<ModelWithProvider[]>,
getFirstAvailableModel,
unsubscribe: () => { },
init,
providers: state.providers,
allModels,
getModel,
getProvider,
getFirstAvailableModel
};
};
};
+4
View File
@@ -1,3 +1,7 @@
<script setup lang="ts">
preloadRouteComponents('/');
</script>
<template>
<div class="h-full w-full grid place-items-center">
<div
-4
View File
@@ -24,10 +24,6 @@ useKeyboardShortcuts();
<Icon class="text-5" name="mynaui:panel-left-open" />
</button>
</div>
<!-- <div class="flex items-center gap-2">
<div id="primary-loader-target"></div>
</div> -->
</div>
<div class="flex-1 overflow-y-auto">
<slot />
+3 -14
View File
@@ -8,16 +8,10 @@ const inputValue = ref('');
const pendingMessage = ref<Message | null>(null);
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string);
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
const { getAgent } = useAgents();
const { providers } = useModels();
const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') {
throw new Error('Invalid agent ID');
}
return getAgent(route.params.id)!;
});
const agent = getAgent(route.params.id as string);
if (!agent.value) navigateTo('/');
@@ -85,11 +79,6 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
}
});
};
onUnmounted(() => {
unsubscribeAgents?.();
unsubscribeModels?.();
});
</script>
+5 -15
View File
@@ -1,18 +1,12 @@
<script setup lang="ts">
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { getAgent } = useAgents();
const triplit = useTriplitClient();
const route = useRoute();
const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') {
throw new Error('Invalid agent ID');
}
const agent = getAgent(route.params.id as string);
return getAgent(route.params.id)!;
});
// if (agent.value === undefined) navigateTo('/');
if (agent.value === undefined) navigateTo('/');
const handleInput = async (e: Event) => {
const target = e.target as HTMLInputElement;
@@ -20,7 +14,7 @@ const handleInput = async (e: Event) => {
return;
}
await triplit.update('agents', agent.value.id, { name: target.value });
await triplit.update('agents', agent.value!.id, { name: target.value });
};
const changeSystemPrompt = async (e: Event) => {
@@ -31,14 +25,10 @@ const changeSystemPrompt = async (e: Event) => {
value = undefined;
}
await triplit.update('agents', agent.value.id, {
await triplit.update('agents', agent.value!.id, {
systemPrompt: target.value,
});
};
onUnmounted(() => {
unsubscribeAgents?.();
});
</script>
<template>
+3 -11
View File
@@ -10,16 +10,10 @@ const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref('');
const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { providers, allModels, unsubscribe: unsubscribeModels } = await useModels();
const { getAgent } = useAgents();
const { providers, allModels } = useModels();
const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') {
throw new Error('Invalid agent ID');
}
return getAgent(route.params.id)!;
});
const agent = getAgent(route.params.id as string);
const topicQuery = computed(() =>
triplit
@@ -273,8 +267,6 @@ onUnmounted(() => {
unsubscribeMessages?.();
unsubscribeParts?.();
unsubscribeGenerations?.();
unsubscribeAgents?.();
unsubscribeModels?.();
});
</script>
+5 -8
View File
@@ -1,12 +1,11 @@
<script setup lang="ts">
import type schema from '#triplit/schema';
import type { Entity } from '@triplit/client';
import { assert } from '~~/utils/assert';
import type { Agent } from '~/composables/useAgents';
const triplit = useTriplitClient();
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
const { providers, unsubscribe: unsubscribeModels, getFirstAvailableModel, allModels } = await useModels();
const { agents, createAgent } = useAgents();
const { providers, getFirstAvailableModel, allModels } = useModels();
const taglines = {
morning: [
@@ -74,12 +73,12 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
};
const agent = computed(() => {
return agents.value?.[0];
return agents.value?.[0] ?? null;
});
const handleChatSubmit = async (message: string, model: ModelWithProvider | null) => {
console.log('Message submitted:', message, agents);
let agent: Entity<typeof schema, 'agents'> | null = agents.value?.[0] ?? null;
let agent: Agent | null = agents.value?.[0] ?? null;
if (!agent) {
const triplit = useTriplitClient();
assert('flush' in triplit);
@@ -162,8 +161,6 @@ onUnmounted(() => {
if (typeWriterInterval !== null) {
clearTimeout(typeWriterInterval);
}
unsubscribeAgents?.();
unsubscribeModels?.();
})
</script>
+3
View File
@@ -0,0 +1,3 @@
<template>
</template>
+10
View File
@@ -0,0 +1,10 @@
export default defineNuxtPlugin(async () => {
// Warm up the caches
await Promise.all([
useAgents().init(),
useModels().init()
]);
console.log('Global data ready');
});
+278 -424
View File
File diff suppressed because it is too large Load Diff
+30 -8
View File
@@ -10,6 +10,7 @@ export default defineNuxtConfig({
},
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'format-detection', content: 'telephone=no' },
{
name: 'description',
content: 'A chat-based AI assistant for your LLMs.',
@@ -81,6 +82,12 @@ export default defineNuxtConfig({
},
},
vue: {
config: {
performance: true,
}
},
vite: {
server: {
allowedHosts: true,
@@ -109,10 +116,10 @@ export default defineNuxtConfig({
'@vue/devtools-kit',
'@sentry/nuxt',
'@nuxt/hints/runtime/hydration/component',
'vue-virtual-scroller',
'@nuxt/hints/runtime/lazy-load/composables',
'@tanstack/vue-virtual',
'shiki',
'better-auth/vue',
'big.js',
'unified',
'remark-gfm',
'remark-parse',
@@ -122,9 +129,17 @@ export default defineNuxtConfig({
'unist-util-visit',
'@triplit/client',
'@triplit/db',
'comlink'
'sorted-btree',
'elen',
'comlink',
'nanoid'
],
},
build: {
commonjsOptions: {
transformMixedEsModules: true,
}
},
},
nitro: {
@@ -138,7 +153,14 @@ export default defineNuxtConfig({
// viewTransition: true,
// },
modules: ['@vue-macros/nuxt', '@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', 'triplit-nuxt', '@sentry/nuxt/module', '@nuxt/fonts'],
modules: [
'@nuxt/hints',
'@nuxt/icon',
'@unocss/nuxt',
'triplit-nuxt',
// '@sentry/nuxt/module',
'@nuxt/fonts'
],
triplit: {
schema_path: './triplit/schema.ts',
@@ -159,10 +181,10 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
sentry: {
org: 'veridian-ol',
project: 'javascript-nuxt',
},
// sentry: {
// org: 'veridian-ol',
// project: 'javascript-nuxt',
// },
sourcemap: {
client: 'hidden',
+7 -8
View File
@@ -8,8 +8,7 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"knip": "knip"
"postinstall": "nuxt prepare"
},
"dependencies": {
"@ai-sdk/cerebras": "^2.0.34",
@@ -18,15 +17,15 @@
"@ai-sdk/openai-compatible": "^2.0.30",
"@daveyplate/better-auth-triplit": "^0.2.2",
"@iconify-json/mynaui": "^1.2.17",
"@nuxt/fonts": "0.13.0",
"@nuxt/fonts": "0.14.0",
"@nuxt/hints": "1.0.0-alpha.5",
"@nuxt/icon": "2.2.0",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@sentry/nuxt": "^10.39.0",
"@tanstack/vue-virtual": "^3.13.18",
"@triplit/client": "^1.0.50",
"@triplit/server": "^1.1.8",
"@types/big.js": "^6.2.2",
"@vue-macros/nuxt": "^3.1.2",
"ai": "^6.0.89",
"ai-sdk-ollama": "^3.7.1",
"better-auth": "^1.4.18",
@@ -34,7 +33,7 @@
"comlink": "^4.4.2",
"glob": "^13.0.5",
"nanoid": "^5.1.6",
"nuxt": "4.2.2",
"nuxt": "^4.3.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
@@ -44,8 +43,7 @@
"triplit-nuxt": "0.3.1-prerelease.5",
"unified": "^11.0.5",
"vue": "^3.5.28",
"vue-router": "^4.6.4",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue-router": "^5.0.3",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -63,6 +61,7 @@
},
"trustedDependencies": [
"@parcel/watcher",
"@sentry/cli",
"core-js",
"esbuild",
"unrs-resolver"
@@ -73,6 +72,6 @@
},
"overrides": {
"@vercel/nft": "^0.27.4",
"vite": "8.0.0-beta.0"
"vite": "8.0.0-beta.15"
}
}
-18
View File
@@ -90,24 +90,6 @@ index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..0ce27b58838db11fd51fcf873a182d52
db = new DB({ ...options, schema: savedSchema });
let schemaChange = undefined;
// A schema is provided, attempt to apply it
diff --git a/dist/index.js b/dist/index.js
index 5155e43bb742663d121d2c5a50275398d8b79b36..9fbd6beac969f25612d74a57b845b973ea914637 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,4 +1,3 @@
-import './polyfills.js';
export * from './codec.js';
export * from './db.js';
export * from './db-transaction.js';
diff --git a/dist/polyfills.d.ts b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.d.ts
deleted file mode 100644
index 825f04fd5dba36fd87c308e87e15b2c3c5be8712..0000000000000000000000000000000000000000
diff --git a/dist/polyfills.js b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.js
deleted file mode 100644
index 264a2151aaf63c967a2f8820454d2692f4522c99..0000000000000000000000000000000000000000
diff --git a/dist/polyfills.js.map b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.js.map
deleted file mode 100644
index 23685d5015568d42f4273c442fff1dacac3151d4..0000000000000000000000000000000000000000
diff --git a/dist/schema/data-types/type.js b/dist/schema/data-types/type.js
index 2e1281c5577b48ffe609a08da933b5f342a485fa..e88487852f6c49185144a9057ffb11d93a3b6cc8 100644
--- a/dist/schema/data-types/type.js
+69 -17
View File
@@ -1,5 +1,4 @@
import { type Entity } from '@triplit/client';
import Big from 'big.js';
import * as z from 'zod';
import { SupportedModalities } from '~/types/model';
import { Providers } from '~/types/model';
@@ -246,14 +245,65 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
}
}
const formatBig = (bigValue: Big) => {
let str = bigValue.toString();
// const formatBig = (bigValue: Big) => {
// let str = bigValue.toString();
if (!str.includes('.')) return str + '.00';
if (str.split('.')[1]!.length === 1) return str + '0';
// if (!str.includes('.')) return str + '.00';
// if (str.split('.')[1]!.length === 1) return str + '0';
return str;
};
// return str;
// };
// this function multiplies a string in the format of 'D.DD' by 1_000_000
// it does this by finding the first digit that is not a zero, and then
// left shifting it in decimal by 3 places
const lshDecimal = (number: string, shift: number) => {
if (number.length === 0) {
return '0.00';
}
let value = '';
let isNegative = number[0] === '-';
let [integerPart, fractionalPart] = number.substring(isNegative ? 1 : 0).split('.');
if (!fractionalPart) {
fractionalPart = '';
}
if (shift <= fractionalPart.length) {
integerPart += fractionalPart.substring(0, shift);
fractionalPart = fractionalPart.substring(shift);
} else if (shift > fractionalPart.length) {
integerPart += fractionalPart;
for (let i = 0; i < shift - fractionalPart.length; i++) {
integerPart += '0';
}
fractionalPart = '';
}
if (isNegative) {
value += '-';
}
integerPart = integerPart!.replace(/^0+/, '');
if (integerPart.length === 0) {
integerPart = '0';
}
value += integerPart;
if (fractionalPart.length > 0) {
if (fractionalPart.length < 2) {
fractionalPart += '0';
}
value += '.' + fractionalPart;
} else {
value += '.00';
}
return value;
}
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any) => {
switch (provider) {
@@ -289,35 +339,37 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
switch (key) {
case 'prompt':
case 'completion':
case 'request':
case 'image':
case 'audio':
case 'discount':
pricing[key] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing[key] = lshDecimal(model.pricing[key], 6);
break;
case 'request':
pricing['request'] = lshDecimal(model.pricing[key], 3);
break;
case 'image_tokens':
pricing['imageTokens'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['imageTokens'] = lshDecimal(model.pricing[key], 6);
break;
case 'image_output':
pricing['imageOutput'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['imageOutput'] = lshDecimal(model.pricing[key], 6);
break;
case 'audio_output':
pricing['audioOutput'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['audioOutput'] = lshDecimal(model.pricing[key], 6);
break;
case 'input_audio_cache':
pricing['inputAudioCache'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['inputAudioCache'] = lshDecimal(model.pricing[key], 6);
break;
case 'web_search':
pricing['webSearch'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['webSearch'] = lshDecimal(model.pricing[key], 3);
break;
case 'internal_reasoning':
pricing['internalReasoning'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['internalReasoning'] = lshDecimal(model.pricing[key], 6);
break;
case 'input_cache_read':
pricing['inputCacheRead'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['inputCacheRead'] = lshDecimal(model.pricing[key], 6);
break;
case 'input_cache_write':
pricing['inputCacheWrite'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
pricing['inputCacheWrite'] = lshDecimal(model.pricing[key], 6);
break;
}
}