feat: ditch triplit, move to postgresql + drizzle orm

This commit is contained in:
Zoe
2026-04-08 17:03:07 -05:00
parent c341c96798
commit e2e3ac6e86
121 changed files with 6680 additions and 4373 deletions
+5 -4
View File
@@ -2,10 +2,11 @@
import '~/assets/css/reset.css';
import '~/assets/css/base.css';
const { accent, neutral, hinting } = useUserSettings();
// by default, disable hinting
if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
const { user } = useAuth();
const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings();
watch(user, () => {
refreshSettings();
})
watchEffect(() => {
useHead({
+1 -6
View File
@@ -32,7 +32,7 @@ const fileExtension = computed(() => {
</script>
<template>
<div class="relative h-full w-fit">
<div class="relative h-full w-fit flex">
<img v-if="isImage" :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
<video v-else-if="isVideo" controls :src="props.file.url"
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
@@ -53,10 +53,5 @@ const fileExtension = computed(() => {
<span class="text-xs text-white">{{ file.progress || 0 }}%</span>
</div>
</div>
<button @click="$emit('delete')"
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 flex items-center justify-center w-4 h-4 rounded-full bg-[var(--bg-base)] border border-[var(--color-border)] text-xs text-[#ff3b3b]">
<span class="i-mynaui-x text-3"></span>
</button>
</div>
</template>
+1 -1
View File
@@ -21,7 +21,7 @@ const handleDelete = async () => {
</script>
<template>
<div class="relative h-full w-fit">
<div class="relative h-full w-fit flex">
<Display :file="props.file" />
<button @click="handleDelete"
+13 -14
View File
@@ -1,14 +1,11 @@
<script setup lang="ts">
import type { BaseMessage } from '~/composables/useChat';
import { onMounted, ref, watch, onUnmounted, nextTick, type Ref } from 'vue';
import { nanoid } from 'nanoid';
import { assert } from '~~/utils/assert';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Agent } from '~/composables/useAgents';
import type FileSelector from './FileSelector.vue';
const { allModels } = useModels();
const { user } = useAuth();
const { allModels } = await useModels();
const inputHeight: Ref<string> = ref('auto');
const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null);
@@ -30,7 +27,6 @@ watch(textAreaValue, (newValue) => {
watch(files, (newFiles) => {
inputValue.value.fileIds = newFiles.map(f => f.id);
});
const triplit = useTriplitClient();
const emit = defineEmits<{
submit: [value: BaseMessage, model: ModelWithProvider | null];
@@ -39,7 +35,7 @@ const emit = defineEmits<{
const props = defineProps<{
loading?: boolean;
agent: Agent | null;
agent: Readonly<Agent> | null;
providers?: ProviderWithModels[];
}>();
@@ -82,8 +78,11 @@ const initializeModel = () => {
const updateAgentDefaultModel = async (modelId: string) => {
if (!props.agent) return;
try {
await triplit.update('agents', props.agent.id, (agent) => {
agent.defaultModelId = modelId;
await $fetch(`/api/agent/${props.agent.id}`, {
method: 'PATCH',
body: {
defaultModelId: modelId,
},
});
} catch (error) {
console.error('Failed to update agent default model:', error);
@@ -114,8 +113,8 @@ const handleSubmit = () => {
}
if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) {
emit('submit', inputValue.value, selectedModel.value);
inputValue.value = { content: '', fileIds: [] };
emit('submit', structuredClone(toRaw(inputValue.value)), selectedModel.value);
files.value = [];
textAreaValue.value = '';
}
// Reset height after sending
@@ -225,12 +224,12 @@ onUnmounted(() => {
<textarea data-gramm="false" id="chat" v-model="textAreaValue" ref="inputRef"
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
@keydown="handleKeyDown" :style="{ height: inputHeight }"
class="[scrollbar-width:none] w-full bg-transparent resize-none text-[0.95em] placeholder:text-[var(--text-tertiary)]"></textarea>
class="[scrollbar-width:none] w-full bg-transparent resize-none placeholder:text-[var(--text-tertiary)]"></textarea>
</div>
<div class="flex items-center justify-between gap-2">
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
<div class="flex flex-1 gap-1">
<div class="flex flex-1 gap-1 min-w-0">
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
:providers="providers" />
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
@@ -239,9 +238,9 @@ onUnmounted(() => {
:disabled="(!inputValue.content.trim() && files.length === 0) && !loading" :class="[
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
(inputValue.content.trim() || files.length > 0) && !loading
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)]'
: 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]',
loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
]">
<span v-if="loading" class="text-6.5 i-mynaui-stop-solid"></span>
<span v-else class="text-5 i-mynaui-send-solid"></span>
+3 -3
View File
@@ -2,7 +2,7 @@
import { useAgents } from '~/composables/useAgents';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
const { agents } = useAgents();
const { agents } = await useAgents();
const route = useRoute();
const emit = defineEmits(['close']);
@@ -134,7 +134,7 @@ onMounted(() => { inputRef.value?.focus(); });
<!-- AGENT ROW -->
<div v-if="item.type === 'agent'" :id="item.uiId" role="option"
:aria-selected="selectedIndex === index" @click="selectItem(item.uiId)"
class="group flex items-center px-3 h-10 rounded-md cursor-pointer transition-colors hover:bg-[var(--color-hover)]"
class="group flex items-center px-3 h-10 rounded-md cursor-pointer transition-colors @hover:bg-[var(--color-hover)]"
:class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<div
:class="['mr-2 w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', item?.imageUrl ? '' : 'border border-[var(--color-border)]']">
@@ -148,7 +148,7 @@ onMounted(() => { inputRef.value?.focus(); });
<!-- TOPIC ROW -->
<div v-else :id="item.uiId" role="option" :aria-selected="selectedIndex === index"
@click="selectItem(item.uiId)"
class="group flex items-center h-10 pr-3 rounded-md cursor-pointer transition-colors relative ml-10 hover:bg-[var(--color-hover)]"
class="group flex items-center h-10 pr-3 rounded-md cursor-pointer transition-colors relative ml-10 @hover:bg-[var(--color-hover)]"
:class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<!-- Visual Tree Lines -->
<div class="absolute -left-4 top-0 bottom-0 w-[1px] h-[110%] bg-[var(--text-dim)]"></div>
+3 -3
View File
@@ -6,7 +6,7 @@ import SystemAssistants from '~/components/Settings/SystemAssistants.vue';
import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue';
import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue';
const { providers } = useModels();
const { providers } = await useModels();
const PAGES_CONFIG = {
general: {
@@ -77,7 +77,7 @@ const runtimePage = computed(() => {
@navigate="(p: string, params?: string) => setOptions({ page: p, params })" :params="props.params" />
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setOptions({ page: id })"
:class="[page === id ? 'bg-[var(--color-hover)]' : 'hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
:class="[page === id ? 'bg-[var(--color-hover)]' : '@hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
<div class="flex items-center gap-2 max-w-full flex-1">
<span :class="['w-5 h-5 text-5', config.icon]"></span>
{{ config.label }}
@@ -91,7 +91,7 @@ const runtimePage = computed(() => {
<header class="flex items-center justify-between pl-2 pb-2 pt-2 ">
<h2 class="text-lg font-semibold m-0 case-capital">{{ 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)]"
class="@hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
@click="close">
<span class="i-mynaui-x-solid"></span>
</button>
+2 -2
View File
@@ -14,10 +14,10 @@ const text = ref(props.initialValue || '');
<textarea v-model="text" class="bg-[var(--bg-surface)] resize-none h-full w-full p-2 rounded" />
<div class="flex justify-end gap-2 m-2">
<button
class="px-2 py-1 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-hover)] transition-[background-color] duration-200"
class="px-2 py-1 rounded-lg border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-[background-color] duration-200"
@click="emit('cancel')">Cancel</button>
<button
class="px-2 py-1 rounded-lg bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200"
class="px-2 py-1 rounded-lg bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200"
@click="emit('confirm', text)">Confirm</button>
</div>
</div>
+10 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, type Placement } from '@floating-ui/vue';
import { useFloating, offset, flip, shift, autoUpdate, type Placement, hide } from '@floating-ui/vue';
const props = defineProps<{
placement?: Placement;
@@ -8,7 +8,7 @@ const props = defineProps<{
}>();
const isOpen = ref(false);
const triggerRef = ref(null);
const triggerRef = ref<HTMLElement | null>(null);
const dropdownRef = ref(null);
const setTriggerRef = (el: any) => {
@@ -18,10 +18,10 @@ const setTriggerRef = (el: any) => {
}
};
const { floatingStyles, placement } = useFloating(triggerRef, dropdownRef, {
const { floatingStyles, placement, middlewareData } = useFloating(triggerRef, dropdownRef, {
placement: props.placement ?? 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })],
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false,
});
@@ -55,8 +55,12 @@ defineExpose({ close });
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-[opacity,transform] 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" ref="dropdownRef" :style="floatingStyles" class="fixed z-9999"
:class="[dropdownClass, transformOrigin]">
<div v-if="isOpen" ref="dropdownRef" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-9999" :class="[dropdownClass, transformOrigin]">
<div v-click-outside="close"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1">
<slot name="dropdown" :close="close" />
+18 -18
View File
@@ -8,8 +8,6 @@ const props = defineProps<{
const imageInputRef = ref<HTMLInputElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const { user } = useAuth();
const files = defineModel<{
id: string;
name: string;
@@ -20,8 +18,6 @@ const files = defineModel<{
}[]>({ required: false, default: [] });
const rawFiles = ref<File[]>([]);
const triplit = useTriplitClient();
const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map());
const uploadWithProgress = (file: File, url: string, id: string) => {
@@ -78,23 +74,25 @@ const uploadFile = async (file: File) => {
const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', {
method: 'POST',
body: JSON.stringify({
body: {
file: {
name: fileName,
mimeType: fileType,
}
}),
},
}) as { url: string; assetUrl: string };
await uploadWithProgress(file, uploadUrl, id);
await triplit.insert('files', {
id,
userId: user.value!.id,
name: fileName,
mimeType: fileType,
url: assetUrl,
});
await $fetch('/api/file', {
method: 'POST',
body: {
id,
name: fileName,
mimeType: fileType,
url: assetUrl,
},
})
files.value = files.value.map(f => {
if (f.name === file.name) {
@@ -151,7 +149,9 @@ watch(files, async (newFiles, oldFiles) => {
// we knpw that if the url starts with blob: it was the first time we uploaded it
// so we can just delete it
if (removedFile.status === 'uploaded') {
await triplit.delete('files', removedFile.id);
await $fetch(`/api/file/${removedFile.id}`, {
method: 'DELETE',
})
}
URL.revokeObjectURL(removedFile.url);
@@ -185,21 +185,21 @@ onUnmounted(() => {
<Dropdown dropdownClass="text-sm" placement="top">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="flex items-center justify-center h-8.5 w-8.5 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="flex items-center justify-center h-8.5 w-8.5 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none i-mynaui-paperclip text-5 text-[var(--text-secondary)]"></span>
</button>
</template>
<template #dropdown="{ close }">
<button
:disabled="selectedModel ? [...selectedModel.attributes.inputModalities].filter(p => p !== 'text').length === 0 : true"
:disabled="selectedModel ? selectedModel.inputModalities.filter(p => p !== 'text').length === 0 : true"
@click="imageInputRef?.click(); close()"
class="text-left px-3 py-1.5 items-center gap-2 enabled:hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50">
class="text-left px-3 py-1.5 items-center gap-2 enabled:@hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50">
<span class="text-4.5 i-tabler-photo-plus"></span>
<span>Upload image</span>
</button>
<button @click="fileInputRef?.click(); close()"
class="text-left px-3 py-1.5 items-center gap-2 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
class="text-left px-3 py-1.5 items-center gap-2 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
<span class="text-4.5 i-mynaui-file-plus"></span>
<span>Upload file</span>
</button>
+4 -3
View File
@@ -7,20 +7,21 @@ defineProps<{
const TITLE = 'Cerebras';
const BACKGROUND_COLOR = "#F15A29";
const BACKGROUND_COLOR = "#FFF";
const AVATAR_SCALE = 0.8;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg fill="currentColor" fill-rule="evenodd" :height="size" style="flex: none; line-height: 1;"
<svg :fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd" :height="size"
style="flex: none; line-height: 1;"
:style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<path clipRule="evenodd"
d="M14.121 2.701a9.299 9.299 0 000 18.598V22.7c-5.91 0-10.7-4.791-10.7-10.701S8.21 1.299 14.12 1.299V2.7zm4.752 3.677A7.353 7.353 0 109.42 17.643l-.901 1.074a8.754 8.754 0 01-1.08-12.334 8.755 8.755 0 0112.335-1.08l-.901 1.075zm-2.255.844a5.407 5.407 0 00-5.048 9.563l-.656 1.24a6.81 6.81 0 016.358-12.043l-.654 1.24zM14.12 8.539a3.46 3.46 0 100 6.922v1.402a4.863 4.863 0 010-9.726v1.402z"
:fill="!avatar && color ? '#F15A29' : ''" :fillRule="!avatar && color ? 'evenodd' : ''" />
:fill="avatar || color ? '#F15A29' : ''" :fillRule="avatar || color ? 'evenodd' : ''" />
<path
d="M15.407 10.836a2.24 2.24 0 00-.51-.409 1.084 1.084 0 00-.544-.152c-.255 0-.483.047-.684.14a1.58 1.58 0 00-.84.912c-.074.203-.11.416-.11.631 0 .218.036.43.11.631a1.594 1.594 0 00.84.913c.2.093.43.14.684.14.216 0 .417-.046.602-.135.188-.09.35-.225.475-.392l.928 1.006c-.14.14-.3.261-.482.363a3.367 3.367 0 01-1.083.38c-.17.026-.317.04-.44.04a3.315 3.315 0 01-1.182-.21 2.825 2.825 0 01-.961-.597 2.816 2.816 0 01-.644-.929 2.987 2.987 0 01-.238-1.21c0-.444.08-.847.238-1.21.15-.35.368-.666.643-.929.278-.261.605-.464.962-.596a3.315 3.315 0 011.182-.21c.355 0 .712.068 1.072.204.361.138.685.36.944.649l-.962.97z" />
</svg>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'ClosedRouter';
const BACKGROUND_COLOR = "#050816";
const AVATAR_SCALE = 0.7;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<g transform="translate(-10.909 -10)" stroke-width="2.1818">
<path
d="m16.364 14.364v6.5455c0 2.41 1.9537 4.3636 4.3636 4.3636h4.3636c2.41 0 4.3636 1.9537 4.3636 4.3636v0"
:stroke="color ? '#00e87a' : 'currentColor'" stroke-linecap="round" stroke-linejoin="round" />
<g :fill="BACKGROUND_COLOR">
<circle cx="16.364" cy="14.364" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
<circle cx="29.455" cy="29.636" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
<circle cx="29.455" cy="14.364" r="3.2727" :stroke="color ? '#7aa2ff' : 'currentColor'" />
</g>
<path d="m20.727 14.364h5.4545" :stroke="color ? '#7aa2ff' : 'currentColor'" stroke-linecap="round" />
</g>
</svg>
</div>
</template>
+2 -2
View File
@@ -15,8 +15,8 @@ const BACKGROUND_COLOR = "#fff";
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
fill="currentColor" fill-rule="evenodd" style="flex: none; line-height: 1;" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
:fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd" style="flex: none; line-height: 1;"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<path
d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" />
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'vLLM';
const BACKGROUND_COLOR = "#fff";
const AVATAR_SCALE = 0.6;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<path d="M0 4.973h9.324V23L0 4.973z" :fill="color ? '#FDB515' : ''" />
<path d="M13.986 4.351L22.378 0l-6.216 23H9.324l4.662-18.649z" :fill="color ? '#30A2FF' : ''" />
</svg>
</div>
</template>
+2 -2
View File
@@ -73,14 +73,14 @@ console.log("shiki codeblock rendered in", Date.now() - start);
</div>
<div class="flex gap-2">
<button @click="copyCode()"
class="flex items-center px-1 gap-0.5 rounded-md hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="flex items-center px-1 gap-0.5 rounded-md @hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
{{ copied ? 'Copied' : 'Copy' }}
<span v-if="!copied" class="i-mynaui-copy text-4 text-[var(--text-secondary)]"></span>
<span v-else class="i-mynaui-check text-4 text-emerald-500"></span>
</button>
<button @click="collapsed = !collapsed"
class="flex items-center justify-center h-5.5 w-5.5 rounded-md hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="flex items-center justify-center h-5.5 w-5.5 rounded-md @hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span
class="i-mynaui-chevron-down text-4 h-4 w-4 text-[var(--text-secondary)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="collapsed ? '-rotate-90' : ''"></span>
+10 -5
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { MessagePart } from '~/composables/useChat';
const props = defineProps<{
part: Readonly<Entity<typeof schema, 'message_parts'>>;
part: Readonly<MessagePart>;
}>();
const reasoningOpen = ref(!props.part.finished);
@@ -30,6 +29,12 @@ const handleScroll = () => {
const topThreshold = 0.02 * clientHeight;
if (scrollHeight <= clientHeight) {
// the container does not have enough content to scroll
scrollState.value = '';
return;
}
if (scrollTop <= topThreshold) {
scrollState.value = 'top';
} else if (scrollTop + 100 >= scrollHeight - clientHeight) {
@@ -53,7 +58,7 @@ const toggleReasoning = async () => {
<template>
<div class="text-[--text-tertiary]">
<button @click="toggleReasoning" :class="[
'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)]',
'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'
]">
<span class="flex items-center gap-1">
@@ -69,7 +74,7 @@ const toggleReasoning = async () => {
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
:class="['reasoning-contaizner max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]">
<div class="p-2">
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</div>
</div>
</div>
+3 -4
View File
@@ -1,12 +1,11 @@
<script lang="ts" setup>
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { MessagePart } from '~/composables/useChat';
const props = defineProps<{
part: Readonly<Entity<typeof schema, 'message_parts'>>;
part: Readonly<MessagePart>;
}>();
</script>
<template>
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</template>
+2 -3
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { ToolCall } from '~/composables/useChat';
const props = defineProps<{
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
toolCall: Readonly<ToolCall>;
}>();
const activeTab = ref('input');
+4 -5
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { ToolCall } from '~/composables/useChat';
import Debug from './Debug.vue';
const props = defineProps<{
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
toolCall: Readonly<ToolCall>;
}>();
const deubgToolCallOpen = ref(false);
@@ -17,7 +16,7 @@ const toggleDebugToolCall = () => {
<template>
<div class="flex flex-col gap-2">
<div
class="select-none w-full hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="select-none w-full @hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-1">
<div
@@ -34,7 +33,7 @@ const toggleDebugToolCall = () => {
<div
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<button @click="toggleDebugToolCall"
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden @hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-search w-3 h-3 text-[var(--text-secondary)]"></span>
</button>
</div>
+2 -2
View File
@@ -25,7 +25,7 @@ const toggleTokenDropdown = () => {
<template>
<div class="flex flex-col w-full gap-2">
<div v-for="part in message.parts" :key="part.id">
<div v-for="part in message.parts?.filter(p => p.content?.trim() !== '' || p.toolCall)" :key="part.id">
<Reasoning v-if="part.type === 'reasoning'" :part="part" />
<Text v-else-if="part.type === 'text'" :part="part" />
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" />
@@ -54,7 +54,7 @@ const toggleTokenDropdown = () => {
</div>
<button ref="triggerRef" @click="toggleTokenDropdown"
class="flex gap-2 hover:bg-[var(--color-hover)] px-1 rounded transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="flex gap-2 @hover:bg-[var(--color-hover)] px-1 rounded transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="flex gap-1 items-center" v-if="message.generation.tokens?.output">
<span class="i-tabler-coins"></span>
{{ message.generation?.tokens?.output }}
@@ -1,16 +1,17 @@
<script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue';
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const tokenDetailsDropdownRef = ref<HTMLElement | null>(null);
const { triggerElement, activeGeneration: generation, close, isOpen } = useTokenDropdown();
const { floatingStyles } = useFloating(triggerElement, tokenDetailsDropdownRef, {
const { floatingStyles, middlewareData } = useFloating(triggerElement, tokenDetailsDropdownRef, {
placement: 'bottom-end',
whileElementsMounted: autoUpdate,
middleware: [
offset(4),
flip(),
shift({ padding: 10 }),
hide(),
],
transform: false,
});
@@ -57,7 +58,12 @@ const formatNumber = (num: number | null | undefined) => {
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen && generation && generation.tokens" v-click-outside="close" ref="tokenDetailsDropdownRef"
:style="floatingStyles"
:style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}"
class="absolute z-50 flex flex-col min-w-[280px] bg-[var(--bg-surface)] rounded-md border border-[var(--color-border)] shadow-2xl text-[var(--text-secondary)] font-sans">
<!-- Output Details Section -->
+4 -5
View File
@@ -1,21 +1,20 @@
<script setup lang="ts">
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import * as schema from '~~/drizzle/schema';
defineProps<{
message: Readonly<Entity<typeof schema, 'messages'> & { attachments: Entity<typeof schema, 'attachments'>[] }>;
message: Readonly<typeof schema.messages.$inferSelect & { attachments: (typeof schema.attachments.$inferSelect & { file: typeof schema.files.$inferSelect })[] }>;
}>();
</script>
<template>
<div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl">
<MarkdownRenderer :finished="true" :content="message.content!" :id="message.id" />
<div v-if="message.attachments.length > 0" class="flex flex-col gap-2">
<div v-if="message.attachments && message.attachments.length > 0" class="flex flex-col gap-2">
<div v-for="attachment in message.attachments" :key="attachment.id"
class="flex flex-wrap items-center gap-2">
<div
class="flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<AttachmentDisplay :file="attachment" />
<AttachmentDisplay :file="attachment.file" />
</div>
</div>
</div>
+73 -49
View File
@@ -1,21 +1,36 @@
<script setup lang="ts">
import type { Message } from '~/composables/useChat';
import { assert } from '~~/utils/assert';
const triplit = useTriplitClient();
const { openDialog } = useDialog();
const { message } = defineProps<{
message: Message
}>();
const focusedIndex = computed({
get: () => {
return message.focusedIndex || 0;
},
set: (newValue) => {
triplit.update('messages', message.id, {
focusedIndex: newValue
const emit = defineEmits<{
regenerate: [];
delete: [];
edit: [value: string];
patch: [updates: Partial<Message>];
}>();
let reqAbortController: AbortController | null = null;
watch(() => message.focusedIndex, async (newValue) => {
if (newValue !== undefined) {
if (reqAbortController) {
reqAbortController.abort();
reqAbortController = null;
}
reqAbortController = new AbortController();
await $fetch(`/api/messages/${message.id}`, {
method: 'PATCH',
body: {
focusedIndex: newValue
},
signal: reqAbortController.signal,
});
}
});
@@ -27,44 +42,42 @@ watch(() => message.children.length, (newCount, oldCount) => {
// e.g. if we we have 4 children, and are focused on the 2nd, if we delete it,
// we want to keep the focus on the 2nd index. It just makes me feel better
if (oldCount > newCount) {
if (message.deleted === true && focusedIndex.value === newCount) {
focusedIndex.value = Math.max(0, newCount - 1);
if (message.deleted === true && (message.focusedIndex || 0) === newCount) {
console.log('focusedIndex Math.max(0, newCount - 1)');
message.focusedIndex = Math.max(0, newCount - 1);
return;
}
if (focusedIndex.value > newCount) {
focusedIndex.value = newCount;
if ((message.focusedIndex || 0) > newCount) {
console.log('focusedIndex newCount');
message.focusedIndex = newCount;
}
return;
}
if (message.deleted === true) {
focusedIndex.value = newCount - 1;
console.log('focusedIndex newCount - 1');
message.focusedIndex = newCount - 1;
return;
}
focusedIndex.value = newCount;
console.log('focusedIndex newCount');
message.focusedIndex = newCount;
});
const activeMessage = computed(() => {
if (message.children.length === 0 || (focusedIndex.value === 0 && !message.deleted)) {
if (message.children.length === 0 || ((message.focusedIndex || 0) === 0 && !message.deleted)) {
return message;
}
if (message.deleted === true) {
return message.children[Math.min(focusedIndex.value, message.children.length - 1)];
return message.children[Math.min((message.focusedIndex || 0), message.children.length - 1)];
}
return message.children[focusedIndex.value - 1];
return message.children[message.focusedIndex! - 1];
});
const emit = defineEmits<{
regenerate: []
delete: []
}>();
const copied = ref(false);
const copyMessage = async () => {
if (activeMessage.value!.role === 'user') {
@@ -91,33 +104,44 @@ const regenerateMessage = async () => {
}
const handleEdit = async () => {
const originalContent = message.content!;
openDialog<string>(DialogType.Textbox, async (res) => {
if (res.ok) {
if (!res.data) return;
await triplit.update('messages', message.id, {
content: res.data
const req = await $fetch(`/api/messages/${message.id}`, {
method: 'PATCH',
body: {
content: res.data
},
onRequest() {
message.content = res.data;
emit('edit', res.data);
},
onResponseError() {
message.content = originalContent;
emit('edit', originalContent);
},
});
assert('flush' in triplit);
await triplit.flush();
if (!req.ok) {
return;
}
await regenerateMessage();
}
}, {
title: 'Edit Message',
initialValue: message.content
initialValue: message.content!
});
};
const deleteMessage = () => {
if (focusedIndex.value !== 0 && focusedIndex.value === message.children.length) {
focusedIndex.value = Math.max(0, focusedIndex.value - 1);
}
// if (focusedIndex.value !== 0 && focusedIndex.value === message.children.length) {
// focusedIndex.value = Math.max(0, focusedIndex.value - 1);
// }
nextTick(() => {
emit('delete');
});
emit('delete');
};
const messageCount = computed(() => {
@@ -138,21 +162,21 @@ const messageCount = computed(() => {
<div class="flex justify-between items-center">
<div>
<div v-if="messageCount > 1" class="flex gap-1">
<Tooltip :inert="focusedIndex === 0" :hotkey="['alt', '[']">
<button @click="focusedIndex = focusedIndex! - 1"
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Tooltip :inert="message.focusedIndex === 0" :hotkey="['alt', '[']">
<button @click="emit('patch', { focusedIndex: message.focusedIndex! - 1 })"
class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-left w-4 h-4 text-[var(--text-secondary)]"
:class="focusedIndex === 0 ? 'opacity-0' : ''"></span>
:class="message.focusedIndex === 0 ? 'opacity-0' : ''"></span>
</button>
</Tooltip>
<span class="text-xs text-[var(--text-secondary)]">
{{ focusedIndex + 1 }} / {{ messageCount }}
{{ (message.focusedIndex || 0) + 1 }} / {{ messageCount }}
</span>
<Tooltip :inert="focusedIndex + 1 === messageCount" :hotkey="['alt', ']']">
<button @click="focusedIndex = focusedIndex + 1"
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Tooltip :inert="(message.focusedIndex || 0) + 1 === messageCount" :hotkey="['alt', ']']">
<button @click="emit('patch', { focusedIndex: (message.focusedIndex || 0) + 1 })"
class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-right w-4 h-4 text-[var(--text-secondary)]"
:class="focusedIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
:class="(message.focusedIndex || 0) + 1 === messageCount ? 'opacity-0' : ''"></span>
</button>
</Tooltip>
</div>
@@ -162,21 +186,21 @@ const messageCount = computed(() => {
class="self-end mt-1 w-fit bg-[var(--bg-container)] text-[var(--text-secondary)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Tooltip :hotkey="['ctrl', 'shift', 'enter']">
<button @click="regenerateMessage"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-refresh text-4.5"></span>
</button>
</Tooltip>
<button v-if="message.role === 'user'" @click="handleEdit"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-pencil text-4.5"></span>
</button>
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span :class="copied ? 'i-mynaui-check text-emerald-500' : 'i-mynaui-copy text-4.5'"></span>
</button>
<Tooltip :hotkey="['ctrl', 'shift', 'backspace']">
<button @click="deleteMessage"
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-hover)]">
class="flex justify-center items-center w-7 h-6 text-red-500 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-trash text-5"></span>
</button>
</Tooltip>
+17 -25
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { type Model } from '~/types/model';
import { type Model } from '~/composables/useModels';
const { updateModel, deleteModel } = await useModels();
const props = withDefaults(defineProps<{
model: Model;
@@ -22,8 +24,6 @@ const emit = defineEmits<{
edit: [model: Model];
}>();
const triplit = useTriplitClient();
const formatContextWindow = (window: number | null | undefined): string => {
if (!window) return '';
if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`;
@@ -31,28 +31,16 @@ const formatContextWindow = (window: number | null | undefined): string => {
return window.toString();
};
const hasCapability = (capability: string): boolean => {
return props.model.attributes.capabilities.has(capability);
};
const hasInputModality = (modality: string): boolean => {
return (props.model.attributes.inputModalities as ReadonlySet<string>).has(modality);
};
const showCost = computed(() => {
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
});
const toggleModel = async (id: string) => {
await triplit.update('models', id, {
await updateModel(id, {
enabled: !props.model.enabled,
});
};
const deleteModel = async () => {
await triplit.delete('models', props.model.id);
};
const handleEdit = () => {
emit('edit', props.model);
};
@@ -79,11 +67,11 @@ const handleEdit = () => {
<div v-if="showEdit" class="flex items-center gap-2">
<!-- Edit button - only for custom models -->
<button v-if="model.isCustom" @click="handleEdit"
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-[var(--color-accent)]/30 text-[var(--color-accent)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 @hover:bg-[var(--color-accent)]/30 text-[var(--color-accent)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-pencil text-3.5"></span>
</button>
<button @click="deleteModel"
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-500/30 text-red-500 transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<button @click="deleteModel(model.id)"
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 @hover:bg-red-500/30 text-red-500 transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-trash text-3.5"></span>
</button>
</div>
@@ -91,7 +79,11 @@ const handleEdit = () => {
<div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]">
<span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap">
Released on {{ model.releasedAt.toISOString().split('T')[0] }}
Released on {{
typeof model.releasedAt === 'string'
? (model.releasedAt as string).split('T')[0]
: model.releasedAt.toISOString().split('T')[0]
}}
</span>
<template v-if="showCost">
<span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center">
@@ -114,23 +106,23 @@ const handleEdit = () => {
<div class="flex items-center gap-1 shrink-0">
<div class="flex items-center gap-0.5">
<div v-if="hasInputModality('image')" title="Vision"
<div v-if="model.inputModalities.includes('image')" title="Vision"
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
<span class="i-mynaui-image text-2.5 text-emerald"></span>
</div>
<div v-if="hasCapability('reasoning')" title="Reasoning"
<div v-if="model.capabilities.includes('reasoning')" title="Reasoning"
class="w-4.5 h-4.5 bg-[color-mix(in_srgb,_transparent_90%,_var(--reasoning-accent)_10%)] rounded flex items-center justify-center">
<span class="i-mynaui-atom text-2.5 text-[var(--reasoning-accent)]"></span>
</div>
<div v-if="hasCapability('tools')" title="Tools"
<div v-if="model.capabilities.includes('tools')" title="Tools"
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
<span class="i-mynaui-tool text-2.5 text-sky"></span>
</div>
</div>
<span v-if="model.attributes.contextWindow"
<span v-if="model.contextWindow"
class="text-xs font-mono text-[var(--text-secondary)] px-1.5 py-0.5 rounded bg-[var(--bg-container)]">
{{ formatContextWindow(model.attributes.contextWindow) }}
{{ formatContextWindow(model.contextWindow) }}
</span>
<Slider v-if="showEdit" :checked="model.enabled" @click="toggleModel(model.id)" />
+20 -17
View File
@@ -1,14 +1,12 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, size } from '@floating-ui/vue';
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort';
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
const { openDialog } = useDialog();
const { allModels } = useModels();
const { allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts();
const props = defineProps<{
@@ -28,7 +26,7 @@ const virtualizerRef = ref<InstanceType<typeof RowVirtualizerFixed> | null>(null
const navigatingWithKeyboard = ref(false);
const focusedOptionId = ref<string | null>(null);
const { floatingStyles, placement } = useFloating(dropdownButton, dropdownContent, {
const { floatingStyles, placement, middlewareData } = useFloating(dropdownButton, dropdownContent, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
@@ -43,6 +41,7 @@ const { floatingStyles, placement } = useFloating(dropdownButton, dropdownConten
},
padding: 10,
}),
hide()
],
transform: false,
});
@@ -65,8 +64,8 @@ const filteredProviders = computed(() => {
const flatOptions = computed(() => {
type FlatItem =
| { type: 'header'; id: string; provider: Entity<typeof schema, 'providers'> }
| { type: 'model'; id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> };
| { type: 'header'; id: string; provider: Provider }
| { type: 'model'; id: string; model: Model; provider: Provider };
const options: FlatItem[] = [];
for (const provider of filteredProviders.value) {
@@ -102,7 +101,7 @@ const scrollFocusedIntoView = () => {
}
};
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
const selectModel = (model: Model, provider: Provider) => {
selectedModel.value = { ...model, provider };
closeDropdown();
};
@@ -212,11 +211,11 @@ onUnmounted(() => {
<template>
<div role="button" @click="toggleDropdown()" 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="cursor-pointer flex items-center min-w-0 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)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)]',
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
]">
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
:model-id="selectedModel.externalId" size="22" />
@@ -236,8 +235,12 @@ onUnmounted(() => {
<KeepAlive>
<div v-if="isOpen" v-click-outside="closeDropdown" ref="dropdownContent" role="listbox"
aria-label="Select model"
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined"
:style="floatingStyles"
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}"
class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden"
:class="transformOrigin">
@@ -259,14 +262,14 @@ onUnmounted(() => {
<template v-else>
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
<template v-slot="{ item, index }">
<template v-slot="{ item }">
<template v-if="item.type === 'header'">
<div
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
{{ item.provider.name }}
<button
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })"
class="flex h-5 w-5 items-center justify-center hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="flex h-5 w-5 items-center justify-center @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4"></span>
</button>
</div>
@@ -275,7 +278,7 @@ onUnmounted(() => {
<button :id="`model-option-${item.model.id}`" role="option"
:aria-selected="focusedOptionId === item.model.id"
@click="selectModel(item.model, item.provider)"
class="text-white w-full h-10.5 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-hover)] transition-colors duration-150"
class="text-white w-full h-10.5 px-4 py-2 flex items-center justify-between @hover:bg-[var(--color-hover)] transition-colors duration-150"
:class="{
'bg-[var(--color-hover)]': selectedModel?.id === item.model.id,
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === item.model.id && navigatingWithKeyboard
@@ -290,7 +293,7 @@ onUnmounted(() => {
<div class="p-1 border-t border-[var(--color-border)]">
<button
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });">
<span class="i-mynaui-cog-four text-4.5"></span>
<span>Manage Providers</span>
+28 -22
View File
@@ -21,7 +21,28 @@ const updateScrollMargin = () => {
}
};
const resizeObserver = new ResizeObserver(updateScrollMargin);
const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.items.length,
getScrollElement: () => props.scrollElement,
estimateSize: () => props.minItemSize,
overscan: props.overscan,
scrollMargin: scrollMargin.value,
getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index,
})));
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
let resizeRafId: number | undefined = undefined;
const resizeObserver = new ResizeObserver(() => {
if (resizeRafId) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = undefined;
rowVirtualizer.value.measure();
updateScrollMargin();
});
});
onMounted(() => {
resizeObserver.observe(containerRef.value!);
@@ -32,29 +53,14 @@ onUnmounted(() => {
resizeObserver.disconnect();
});
const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.items.length,
getScrollElement: () => props.scrollElement,
estimateSize: () => props.minItemSize,
overscan: props.overscan,
scrollMargin: scrollMargin.value,
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
// Keep the measure function simple
const measureElement = (el: any) => {
if (el) {
rowVirtualizer.value.measureElement(el);
}
rowVirtualizer.value.measureElement(el)
}
};
watch(() => props.items, () => {
rowVirtualizer.value.measure();
@@ -69,7 +75,7 @@ watch(() => props.items, () => {
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
minHeight: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}">
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
+1 -1
View File
@@ -2,7 +2,7 @@
import { useVirtualizer } from '@tanstack/vue-virtual';
const props = defineProps<{
items: any[];
items: readonly any[] | any[];
keyField?: string;
scrollElement: HTMLElement | null;
itemSize: number;
+120 -108
View File
@@ -1,16 +1,15 @@
<script setup lang="ts">
import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { providerBaseUrls, type Model } from '~/types/model';
import { providerBaseUrls, Providers, type Model } from '~/types/model';
import ModelItem from './ModelItem.vue';
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
const triplit = useTriplitClient();
const props = defineProps<{
params?: string;
}>();
const { providers } = useModels();
const { providers, updateProvider, createModel, updateModel } = await useModels();
const scrollContainerRef = ref<HTMLDivElement | null>(null);
@@ -65,11 +64,13 @@ if (import.meta.client) {
};
const toggleProvider = async () => {
await triplit.update('providers', provider.value!.id, {
await updateProvider(provider.value!.id, {
enabled: !provider.value!.enabled,
});
};
let apiKeyTimeout: NodeJS.Timeout | undefined;
const updateApiKey = async (value: string) => {
if (!provider.value) return;
@@ -82,91 +83,63 @@ const updateApiKey = async (value: string) => {
);
const encypted = await encryptData(key, value);
await triplit.update('providers', provider.value.id, {
config: {
...provider.value.config,
apiKey: uint8ArrayToBase64(encypted),
},
});
if (apiKeyTimeout) {
clearTimeout(apiKeyTimeout);
}
apiKeyTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
apiKey: uint8ArrayToBase64(encypted),
},
});
}, 700);
};
let proxyUrlTimeout: NodeJS.Timeout | undefined;
const updateProxyUrl = async (value: string) => {
if (!provider.value) return;
apiProxyUrl.value = value;
await triplit.update('providers', provider.value.id, {
config: {
...provider.value.config,
apiProxyUrl: value,
},
});
if (proxyUrlTimeout) {
clearTimeout(proxyUrlTimeout);
}
proxyUrlTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
apiProxyUrl: value,
},
});
}, 700);
};
const fetchingModels = ref(false);
const fetchModels = async () => {
const { user } = useAuth()
fetchingModels.value = true;
try {
const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, {
const response = await $fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'POST',
body: JSON.stringify({
body: {
providerApiKey: apiKey.value
})
}) as any;
const existingModelsMap = new Map(
(provider.value?.models || []).map((m: any) => [m.externalId, m])
);
const toInsert: any[] = [];
const toUpdate: { id: string, data: any }[] = [];
for (const model of modelsData.models) {
const existing = existingModelsMap.get(model.id);
if (existing) {
const { id, ...existingWithoutId } = model;
console.log("existingWithoutId", existingWithoutId);
toUpdate.push({
id: existing.id,
data: existingWithoutId,
});
} else {
toInsert.push({
userId: user.value?.id!,
externalId: model.id,
providerId: provider.value!.id!,
name: model.name || model.id,
cost: model.cost || {},
attributes: model.attributes,
isCustom: false,
enabled: false,
releasedAt: model.releasedAt,
});
}
});
// Simply update the local state with the returned models
if (provider.value && response.models) {
provider.value.models = response.models as Model[];
}
// delete models that are not in the API response and are not custom models
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)
);
console.log({ toUpdate, toInsert, toDelete });
await Promise.all([
...toInsert.map(item => triplit.insert('models', item)),
...toUpdate.map(item => triplit.update('models', item.id, item.data)),
...toDelete.map(item => triplit.delete('models', item.id))
]);
} catch (error) {
// Optional: show a success toast
} catch (error: any) {
console.error('Failed to fetch models:', error);
// handle error (toast, etc)
} finally {
fetchingModels.value = false;
}
@@ -175,23 +148,64 @@ const fetchModels = async () => {
const deleteModels = async () => {
if (!provider.value) return;
await Promise.all(provider.value.models.map(m => triplit.delete('models', m.id)));
provider.value!.models = [];
await $fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'DELETE',
});
};
const enableAllModels = async () => {
if (!provider.value) return;
await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, {
enabled: true
})));
const originalModels = provider.value.models;
await $fetch(`/api/provider/${provider.value.id}/models`, {
method: 'PATCH',
body: {
enabled: true
},
onRequest() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = provider.value?.models.map(m => ({ ...m, enabled: true })) ?? [];
},
onResponseError() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = originalModels;
},
});
};
const disableAllModels = async () => {
if (!provider.value) return;
await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, {
enabled: false
})));
const originalModels = provider.value.models;
await $fetch(`/api/provider/${provider.value.id}/models`, {
method: 'PATCH',
body: {
enabled: false
},
onRequest() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = provider.value.models.map(m => ({ ...m, enabled: false }));
},
onResponseError() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = originalModels;
},
});
};
const enabledModels = computed(() =>
@@ -272,13 +286,13 @@ const openEditPanel = (model: Model) => {
formData.value = {
name: model.name || '',
externalId: model.externalId || '',
contextWindow: model.attributes?.contextWindow?.toString() || '',
capabilities: [...(model.attributes?.capabilities || [])],
contextWindow: model.contextWindow?.toString() || '',
capabilities: model.capabilities,
promptCost: model.cost?.prompt || '',
completionCost: model.cost?.completion || '',
reasoning: model.attributes?.capabilities?.has('reasoning') || false,
tools: model.attributes?.capabilities?.has('tools') || false,
vision: model.attributes?.capabilities?.has('vision') || false,
reasoning: model.capabilities.includes('reasoning'),
tools: model.capabilities.includes('tools'),
vision: model.capabilities.includes('vision'),
};
showAddModelPanel.value = true;
};
@@ -289,11 +303,11 @@ const { user } = useAuth();
const previewModel = computed(() => {
if (!formData.value.name || !formData.value.externalId) return null;
const inputModalities = new Set<string>(['text']);
const outputModalities = new Set<string>(['text']);
const inputModalities = ['text'];
const outputModalities = ['text'];
if (formData.value.capabilities.includes('vision')) {
inputModalities.add('image');
inputModalities.push('image');
}
return {
@@ -306,15 +320,15 @@ const previewModel = computed(() => {
prompt: formData.value.promptCost ? formatMoney(formData.value.promptCost) : undefined,
completion: formData.value.completionCost ? formatMoney(formData.value.completionCost) : undefined,
},
attributes: {
inputModalities,
outputModalities,
capabilities: new Set(formData.value.capabilities.filter(c => c !== 'vision') as any),
contextWindow: formData.value.contextWindow ? parseInt(formData.value.contextWindow) : null,
},
inputModalities,
outputModalities,
capabilities: formData.value.capabilities.filter(c => c !== 'vision') as any,
contextWindow: formData.value.contextWindow ? parseInt(formData.value.contextWindow) : null,
supportedParameters: [],
isCustom: true,
enabled: true,
provider: provider.value!,
releasedAt: null,
} as Model;
});
@@ -323,11 +337,9 @@ const saveCustomModel = async () => {
const { id, ...previewModelWithoutId } = previewModel.value!;
if (editingModel.value) {
// Update existing model
await triplit.update('models', editingModel.value.id, previewModelWithoutId);
await updateModel(editingModel.value!.id, { ...previewModelWithoutId });
} else {
// Insert new custom model
await triplit.insert('models', previewModelWithoutId);
await createModel(previewModelWithoutId);
}
showAddModelPanel.value = false;
@@ -382,7 +394,7 @@ defineEmits(['navigate']);
autocomplete="false" spellcheck="false"
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
<button @click="apiKeyVisible = !apiKeyVisible"
class="text-sm p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
class="text-sm p-2 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span>
</button>
</div>
@@ -391,7 +403,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="provider.type ? providerBaseUrls[provider.type] ?? '' : ''"
<input :placeholder="provider.type ? providerBaseUrls[provider.type as typeof Providers[number]] : ''"
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)" />
@@ -411,7 +423,7 @@ defineEmits(['navigate']);
Model List
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
{{ provider?.models.length }} models available <button
class="p-0.5 hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
class="p-0.5 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
@click="deleteModels">
<span class="i-mynaui-x-solid"></span>
</button>
@@ -423,20 +435,20 @@ defineEmits(['navigate']);
<input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch"
type="text" placeholder="Search models..." />
<button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''"
class="right-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5">
class="right-1 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5">
<span class="i-mynaui-x-solid text-3.5 block text-[var(--text-secondary)]"></span>
</button>
</div>
<button @click="fetchModels"
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] text-sm rounded-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] text-sm rounded-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-refresh" :class="{ 'animate-rotate': fetchingModels }"></span>
fetch models
</button>
<div class="flex">
<button @click="openAddPanel"
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] text-sm rounded-l-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] text-sm rounded-l-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-plus text-5"></span>
</button>
@@ -444,19 +456,19 @@ defineEmits(['navigate']);
<Dropdown placement="bottom-end">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] text-sm rounded-r-md items-center px-1 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] text-sm rounded-r-md items-center px-1 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-dots-vertical text-5"></span>
</button>
</template>
<template #dropdown="{ close }">
<button @click="enableAllModels(); close()"
class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
<span class="text-5 i-mynaui-toggle-right-solid"></span>
Enable All
</button>
<button @click="disableAllModels(); close()"
class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
<span class="text-5 i-mynaui-toggle-left"></span>
Disable All
</button>
@@ -481,7 +493,7 @@ defineEmits(['navigate']);
{{ editingModel ? 'Edit Custom Model' : 'Add Custom Model' }}
</h5>
<button @click="cancelPanel"
class="p-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200">
class="p-1 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200">
<span class="i-mynaui-x text-4 text-[var(--text-secondary)]"></span>
</button>
</div>
@@ -518,7 +530,7 @@ defineEmits(['navigate']);
class="px-3 py-1.5 text-xs rounded-md border transition-colors duration-200 capitalize"
:class="formData.capabilities.includes(cap)
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
: 'bg-[var(--bg-surface)] border-[var(--color-border)] text-[var(--text-secondary)] hover:border-[var(--color-accent)]'">
: 'bg-[var(--bg-surface)] border-[var(--color-border)] text-[var(--text-secondary)] @hover:border-[var(--color-accent)]'">
{{ cap }}
</button>
</div>
@@ -565,11 +577,11 @@ defineEmits(['navigate']);
</span>
<div class="flex gap-2">
<button @click="cancelPanel"
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)] rounded-md transition-colors duration-200">
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-md transition-colors duration-200">
Cancel
</button>
<button @click="saveCustomModel" :disabled="!isFormValid"
class="px-3 py-1.5 text-sm bg-[var(--color-accent)] text-white rounded-md hover:opacity-90 transition-opacity duration-200 disabled:opacity-50 disabled:cursor-not-allowed">
class="px-3 py-1.5 text-sm bg-[var(--color-accent)] text-white rounded-md @hover:opacity-90 transition-opacity duration-200 disabled:opacity-50 disabled:cursor-not-allowed">
{{ editingModel ? 'Save Changes' : 'Add Model' }}
</button>
</div>
@@ -595,7 +607,7 @@ defineEmits(['navigate']);
</span>
<div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="enabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20">
:scroll-element="scrollContainerRef" :min-item-size="68" :overscan="20">
<template v-slot="{ item: model }">
<ModelItem :model="model" @edit="openEditPanel" />
</template>
@@ -609,7 +621,7 @@ defineEmits(['navigate']);
</span>
<div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="disabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20">
:scroll-element="scrollContainerRef" :min-item-size="68" :overscan="20">
<template v-slot="{ item: model }">
<ModelItem :model="model" @edit="openEditPanel" />
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
const { colorScheme, settings, updateSettings } = useUserSettings();
const { colorScheme, settings, updateSettings } = await useUserSettings();
const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta'];
const neutrals = ['zinc', 'slate', 'obsidian'];
@@ -27,19 +27,19 @@ defineEmits(['navigate']);
<h4 class="font-medium">Theme</h4>
<div class="flex gap-2">
<button @click="updateSettings({ appearance: { colorScheme: 'system' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-tabler-device-desktop text-4"></span>
<span>System</span>
</button>
<button @click="updateSettings({ appearance: { colorScheme: 'dark' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-mynaui-moon text-4"></span>
<span>Dark</span>
</button>
<button @click="updateSettings({ appearance: { colorScheme: 'light' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-mynaui-sun text-4"></span>
<span>Light</span>
@@ -51,7 +51,7 @@ defineEmits(['navigate']);
<h4 class="text-sm font-medium">Accent Color</h4>
<div class="grid grid-cols-5 gap-2">
<button v-for="accent in accents" :key="accent" @click="updateAccent(accent)"
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
class="h-8 rounded border-2 @hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
:class="[
settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent'
]" :style="`background-color: var(--accent-${accent})`" :title="accent" />
@@ -62,9 +62,9 @@ defineEmits(['navigate']);
<h4 class="text-sm font-medium">Neutral Color</h4>
<div class="grid grid-cols-5 gap-2">
<button v-for="neutral in neutrals" :key="neutral" @click="updateNeutral(neutral)"
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
class="h-8 rounded border-2 @hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
:class="[
settings.appearance.neutral === neutral ? 'border-[var(--color-accent)]' : 'border-transparent hover:border-zinc-500'
settings.appearance.neutral === neutral ? 'border-[var(--color-accent)]' : 'border-transparent @hover:border-zinc-500'
]" :style="`background-color: var(--palette-${neutral}-200)`" :title="neutral" />
</div>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ const handleEdit = (model: Model) => {
<template>
<div
class="p-3 text-white flex max-w-full items-center justify-between gap-2 group hover:bg-[var(--color-hover)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="min-h-17 p-3 text-white flex max-w-full items-center justify-between gap-2 group @hover:bg-[var(--color-hover)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
:show-release-date="true" @edit="handleEdit" />
</div>
+4 -31
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { Providers } from '~/types/model';
import { providerIcons } from '~/utils/model-mapping';
const triplit = useTriplitClient();
const { providers } = useModels();
const { providers, updateProvider } = await useModels();
const props = defineProps<{
params?: string;
@@ -11,32 +9,11 @@ const props = defineProps<{
if (providers.value === undefined) throw new Error('Providers not loaded');
// TODO: sometimes this code can create duplicate providers
const { user } = useAuth();
for (const provider of Providers) {
if (!providers.value?.find(p => p.type === provider)) {
// create a new provider
await triplit.insert('providers', {
name: provider,
userId: user.value!.id,
type: provider,
enabled: false,
config: {},
});
}
}
for (const provider of providers.value) {
if (!Providers.includes(provider.type)) {
await triplit.delete('providers', provider.id);
}
}
const toggleProvider = async (id: string) => {
const provider = providers.value!.find(p => p.id === id);
if (!provider) return;
await triplit.update('providers', provider.id, {
await updateProvider(provider.id, {
enabled: !provider.enabled,
});
};
@@ -56,7 +33,7 @@ defineEmits(['navigate']);
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]">
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
@@ -66,8 +43,6 @@ defineEmits(['navigate']);
<hr class="border-t border-[var(--color-border)]" />
</div>
<div class="flex items-center justify-end">
<!-- <input type="checkbox"
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div>
</button>
@@ -82,7 +57,7 @@ defineEmits(['navigate']);
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]">
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
@@ -92,8 +67,6 @@ defineEmits(['navigate']);
<hr class="border-t border-[var(--color-border)]" />
</div>
<div class="flex items-center justify-end">
<!-- <input type="checkbox"
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div>
</button>
+5 -5
View File
@@ -4,7 +4,7 @@ import { providerIcons } from '~/utils/model-mapping';
defineProps<{
params?: string;
}>();
const { providers } = useModels();
const { providers } = await useModels();
defineEmits(['navigate']);
</script>
@@ -12,19 +12,19 @@ defineEmits(['navigate']);
<template>
<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)]">
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)]">
<span class="i-mynaui-chevron-left text-4"></span> Back to General
</button>
<button @click="$emit('navigate', 'providers')"
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)]">
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)]">
<span class="i-mynaui-envelope-open text-4"></span> All
</button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div>
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
:class="['case-capital flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-4 h-4 text-[var(--text-primary)]" />
@@ -36,7 +36,7 @@ defineEmits(['navigate']);
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
@click="$emit('navigate', 'providers', p.id)"
:class="['case-capital flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-4 h-4 text-[var(--text-primary)]" />
+2 -3
View File
@@ -5,8 +5,8 @@ const props = defineProps<{
params?: string;
}>();
const { providers, allModels } = useModels();
const { settings, updateSettings } = useUserSettings();
const { providers, allModels } = await useModels();
const { settings, updateSettings } = await useUserSettings();
const toggle = async (key: string) => {
const current = (settings.value.systemAssistants as any)[key];
@@ -39,7 +39,6 @@ const getModel = (id: string | null | undefined) => {
}
defineEmits(['navigate']);
</script>
<template>
+3 -8
View File
@@ -1,7 +1,4 @@
<script setup lang="ts">
import { assert } from '~~/utils/assert';
const triplit = useTriplitClient();
const { user, signOut } = useAuth();
// to prevent the user details from going blank for a
@@ -23,8 +20,6 @@ const { isHovered } = useSidenavContext();
const handleLogout = async () => {
await signOut();
assert('disconnect' in triplit);
triplit.disconnect();
await navigateTo('/auth/login');
};
@@ -33,7 +28,7 @@ const handleLogout = async () => {
<template>
<header class="flex items-center justify-between overflow-hidden">
<button @click="dropdownOpen = !dropdownOpen"
class="flex gap-1 pr-1 items-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="flex gap-1 pr-1 items-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-border)]']">
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
@@ -58,13 +53,13 @@ const handleLogout = async () => {
<div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false"
class="w-full top-full text-sm mt-1 absolute z-30 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
<button @click="dropdownOpen = false; openDialog(DialogType.Settings)"
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4.5"></span>
<span>Settings</span>
</button>
<hr class="border-t border-[var(--color-border)]" />
<button @click="handleLogout"
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-logout text-4.5"></span>
<span>Log out</span>
</button>
+4 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
const route = useRoute();
const { agents, getAgent } = useAgents();
const { agents, getAgent } = await useAgents();
const agentId = computed(() => route.params.id as string);
const activeAgent = getAgent(agentId);
@@ -13,7 +13,7 @@ const { isHovered } = useSidenavContext();
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'"
:class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
<NuxtLink to="/"
class="flex hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5">
class="flex @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5">
<span class="i-mynaui-chevron-left text-4.5"></span>
</NuxtLink>
</div>
@@ -21,7 +21,7 @@ const { isHovered } = useSidenavContext();
<Dropdown placement="bottom" dropdown-class="max-w-48">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="shrink-1 max-w-full min-w-0 transition duration-200 pr-2 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg">
class="shrink-1 max-w-full min-w-0 transition duration-200 pr-2 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg">
<div class="pointer-events-none flex items-center gap-1.5 max-w-full">
<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)]']">
@@ -43,7 +43,7 @@ const { isHovered } = useSidenavContext();
<template #dropdown="{ close }">
<button v-for="agent in agents" :key="agent.id" @click="navigateTo(`/agent/${agent.id}`); close()"
class="truncate block w-full text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
class="truncate block w-full text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
{{ agent.name }}
</button>
</template>
+19 -19
View File
@@ -13,25 +13,7 @@ const props = defineProps<{
props.icon ? 'px-1' : 'px-2',
props.active
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
<span class="text-4.5" :class="props.icon"></span>
</div>
<div class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
}}</span>
<slot />
</div>
</div>
</NuxtLink>
<button v-else v-bind="$attrs" :aria-label="props.name" :class="[
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
props.icon ? 'px-1' : 'px-2',
props.active
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
: '@hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
@@ -43,5 +25,23 @@ const props = defineProps<{
<slot />
</div>
</div>
</NuxtLink>
<button v-else v-bind="$attrs" :aria-label="props.name" :class="[
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
props.icon ? 'px-1' : 'px-2',
props.active
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: '@hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
<span class="text-4.5" :class="props.icon"></span>
</div>
<div class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
}}</span>
<slot />
</div>
</div>
</button>
</template>
+32 -67
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { assert } from '~~/utils/assert';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue';
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const dropdownOpen = ref(false);
const dropdownTrigger = ref<HTMLElement | null>(null);
@@ -9,9 +8,8 @@ const dropdownContent = ref(null);
const activeMenuTopicId = ref<string | null>(null);
const route = useRoute();
const { getAgent } = useAgents();
const triplit = useTriplitClient();
const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
const { autoRename } = useTopic();
const navRef = ref<HTMLElement | null>(null);
const agentId = computed(() => route.params.id as string);
@@ -27,10 +25,10 @@ const topics = computed(() => {
return activeAgent.value?.topics || [];
})
const { floatingStyles, placement } = useFloating(dropdownTrigger, dropdownContent, {
const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
placement: 'bottom-end',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })],
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false,
});
@@ -52,59 +50,16 @@ const menuTopic = computed(() =>
);
const topicsOpen = ref(true);
let activeAutoRenames = reactive(new Map<string, string>());
const autoRenameTopic = async (topicId: string) => {
const { setPage } = useSettings();
const { autoRename, AutoRenameError } = useChat(agentId.value);
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
if (!firstMessage) return;
const res = await autoRename(topicId, firstMessage.content);
if (res.ok) {
activeAutoRenames.set(topicId, res.data);
return;
}
switch (res.error) {
case AutoRenameError.NoModelSelected:
case AutoRenameError.ModelDisabled:
case AutoRenameError.AutoRenameDisabled: {
setPage('systemAssistants');
} break;
case AutoRenameError.NoModelFound:
case AutoRenameError.DatabaseOperationFailed:
case AutoRenameError.FailedToGenerate:
case AutoRenameError.FailedToDecryptProviderApiKey: {
console.error('Failed to auto-rename:', res.error);
await triplit.update('topics', topicId, {
renaming: false,
});
assert('flush' in triplit);
await triplit.flush();
} break;
}
autoRename(topicId);
}
const cancelAutoRename = async (topicId: string) => {
await triplit.update('topics', topicId, {
renaming: false,
});
const renameId = activeAutoRenames.get(topicId);
if (!renameId) return;
await $fetch(`/api/auto-rename/cancel`, {
body: {
renameId,
},
// TODO: make this more optimistic
await $fetch(`/api/topic/${topicId}/auto-rename/cancel`, {
method: 'POST',
});
activeAutoRenames.delete(topicId);
}
const renameTopicId = ref<string | null>(null);
@@ -123,10 +78,16 @@ const startRename = (topicId: string, currentName: string) => {
const saveRename = async () => {
if (renameTopicId.value && newTopicName.value.trim()) {
await triplit.update('topics', renameTopicId.value, {
name: newTopicName.value.trim()
patchTopicLocally(renameTopicId.value, { name: newTopicName.value.trim() });
$fetch(`/api/topic/${renameTopicId.value}`, {
method: 'PATCH',
body: {
name: newTopicName.value.trim(),
},
});
}
cancelRename();
};
@@ -143,9 +104,8 @@ const deleteTopic = async (topicId: string) => {
await navigateTo('/');
}
}
await triplit.delete('topics', topicId);
// TODO: deeply delete all messages, generations, and message_parts in the topic
deleteAgentTopic(agentId.value!, topicId);
};
const handleNavClick = (e: MouseEvent) => {
@@ -193,7 +153,7 @@ onMounted(() => {
<!-- Topics Section -->
<button @click="topicsOpen = !topicsOpen"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
<span class="text-sm font-medium">Topics</span>
<span
class="i-mynaui-chevron-down inline-block text-4 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
@@ -207,7 +167,7 @@ onMounted(() => {
<template v-slot="{ item: topic }">
<a :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
:href="`/agent/${agentId}/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="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"
@@ -221,7 +181,7 @@ onMounted(() => {
</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)]">
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="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span>
</div>
</a>
@@ -237,32 +197,37 @@ onMounted(() => {
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-1">
<div v-if="dropdownOpen" ref="dropdownContent" :style="floatingStyles" class="fixed z-15"
:class="transformOrigin">
<div v-if="dropdownOpen" ref="dropdownContent" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-15" :class="transformOrigin">
<div v-click-outside="closeDropdown"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
<!-- Dynamic content based on menuTopic -->
<template v-if="menuTopic">
<button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Cancel Auto Rename
</button>
<button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Auto Rename
</button>
<button :disabled="menuTopic.renaming ?? false"
@click="startRename(menuTopic.id, menuTopic.name); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Rename
</button>
<div class="h-px bg-[var(--color-border)] my-1" />
<button @click="deleteTopic(menuTopic.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
Delete
</button>
</template>
@@ -271,4 +236,4 @@ onMounted(() => {
</Transition>
</Teleport>
</nav>
</template>
</template>
+31 -33
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue';
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
import type { DialogType } from '~/composables/useDialog';
const { openDialog } = useDialog();
const { agents, createAgent } = useAgents();
const { agents, createAgent, deleteAgent, updateAgent } = await useAgents();
const agentsOpen = ref(true);
const creatingAgent = ref(false);
@@ -12,21 +12,13 @@ const dropdownTrigger = ref<HTMLElement | null>(null);
const dropdownContent = ref(null);
const activeMenuAgentId = ref<string | null>(null);
const triplit = useTriplitClient();
const navRef = ref<HTMLElement | null>(null);
const toggleAgentsList = () => {
agentsOpen.value = !agentsOpen.value;
};
const newAgent = async () => {
creatingAgent.value = true;
try {
const agent = await createAgent();
return navigateTo(`/agent/${agent.id}`);
} finally {
creatingAgent.value = false;
}
await createAgent();
};
const renameAgentId = ref<string | null>(null);
@@ -44,11 +36,7 @@ const startRename = (agentId: string, currentName: string) => {
};
const saveRename = async () => {
if (renameAgentId.value && newAgentName.value.trim()) {
await triplit.update('agents', renameAgentId.value, {
name: newAgentName.value.trim()
});
}
updateAgent(renameAgentId.value!, { name: newAgentName.value.trim() });
cancelRename();
};
@@ -57,14 +45,10 @@ const cancelRename = () => {
newAgentName.value = '';
};
const deleteAgent = async (agentId: string) => {
await triplit.delete('agents', agentId);
}
const { floatingStyles, placement } = useFloating(dropdownTrigger, dropdownContent, {
const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
placement: 'bottom-end',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })],
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false,
});
@@ -81,7 +65,7 @@ const closeDropdown = () => {
};
const menuAgent = computed(() =>
agents.value.find(t => t.id === activeMenuAgentId.value)
agents.value.find(a => a.id === activeMenuAgentId.value)
);
const handleNavClick = (e: MouseEvent) => {
@@ -121,12 +105,21 @@ onMounted(() => {
<template>
<nav ref="navRef" class="flex flex-col gap-1 overflow-auto">
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search" />
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search">
<div class="flex items-center gap-1">
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">ctrl</kbd>
</span>
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">k</kbd>
</span>
</div>
</SidenavItem>
<SidenavItem to="/" name="Home" icon="i-mynaui-home" />
<!-- Header Toggle -->
<button
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg hover:bg-[var(--color-hover)] transition-colors w-full"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors w-full"
@click="toggleAgentsList">
<span class="text-sm">Agents</span>
<span
@@ -138,7 +131,7 @@ onMounted(() => {
<Collapsible :is-open="agentsOpen">
<div class="flex flex-col transform-origin-top pt-1 pb-1 px-1">
<button @click="newAgent" :disabled="creatingAgent"
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--text-secondary)] hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 w-full">
class="disabled:cursor-wait flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 w-full">
<div class="h-7 w-7 flex items-center justify-center">
<span v-if="creatingAgent" class="i-svg-spinners-ring-resize text-4.5"></span>
<span v-else class="i-mynaui-plus text-4.5"></span>
@@ -152,9 +145,10 @@ onMounted(() => {
<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)]">
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="renameAgentId === agent.id" id="agent-rename-input" v-model="newAgentName"
@keydown.stop.enter="saveRename" @keydown.escape="cancelRename" @blur="saveRename"
@keydown.prevent.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" />
<span v-else
class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
@@ -162,7 +156,7 @@ onMounted(() => {
</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)]">
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="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span>
</div>
</a>
@@ -179,21 +173,25 @@ onMounted(() => {
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-1">
<div v-if="dropdownOpen" ref="dropdownContent" :style="floatingStyles" class="fixed z-15"
:class="transformOrigin">
<div v-if="dropdownOpen" ref="dropdownContent" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-15" :class="transformOrigin">
<div v-click-outside="closeDropdown"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
<template v-if="menuAgent">
<button @click="startRename(menuAgent.id, menuAgent.name); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Rename
</button>
<div class="h-px bg-[var(--color-border)] my-1" />
<button @click="deleteAgent(menuAgent.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
Delete
</button>
</template>
+3 -3
View File
@@ -122,7 +122,7 @@ const navKind = computed(() => {
:class="['flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right']">
<Tooltip :hotkey="['ctrl', '[']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit',
'flex text-5 h-8 w-8 items-center justify-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit',
]">
<span
class="i-mynaui-panel-left-close text-5 transition-inherit transform-origin-right-center"
@@ -133,7 +133,7 @@ const navKind = computed(() => {
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
<Tooltip :hotkey="['ctrl', 'alt', 'n']">
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit',
'flex text-5 h-8 w-8 items-center justify-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit',
]">
<span class="i-mynaui-book-plus text-5"></span>
</NuxtLink>
@@ -151,7 +151,7 @@ const navKind = computed(() => {
<div class="flex">
<Tooltip :hotkey="['ctrl', ',']">
<button @click="openDialog(DialogType.Settings)"
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]">
class="flex items-center justify-center h-7 w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]">
<span class="i-mynaui-cog-four text-5"></span>
</button>
</Tooltip>
+4 -4
View File
@@ -7,7 +7,7 @@ const props = defineProps<{
type Theme = 'light' | 'dark' | 'system';
const { updateSettings, settings } = useUserSettings();
const { updateSettings, settings } = await useUserSettings();
const selectTheme = (colorScheme: Theme) => {
updateSettings({ appearance: { colorScheme } });
@@ -20,7 +20,7 @@ const themeOptions: DropdownItem[] = [
];
const currentOption = computed(
() => themeOptions.find((option) => option.id === settings.value.appearance.colorScheme) || themeOptions[2],
() => themeOptions.find((option) => option.id === settings.value?.appearance?.colorScheme) || themeOptions[2],
);
</script>
@@ -28,7 +28,7 @@ const currentOption = computed(
<Dropdown placement="top-end" dropdown-class="min-w-35">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="flex items-center justify-center rounded-lg hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors active:text-[var(--text-primary)]"
class="flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors active:text-[var(--text-primary)]"
:class="{
'h-7 w-7 text-5': size === 'small',
'h-9 w-9 text-6': size === 'medium',
@@ -40,7 +40,7 @@ const currentOption = computed(
<template #dropdown="{ close }">
<button v-for="option in themeOptions" :key="option.id" @click="option.onClick!(); close()"
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
:class="{
'bg-[var(--color-hover)]': option.id === currentOption?.id,
}">
+7 -1
View File
@@ -6,8 +6,14 @@ const props = defineProps<{
const { show, hide } = useTooltip();
const triggerRef = ref<HTMLElement | null>(null);
const hasCursor = ref(false);
onMounted(() => {
hasCursor.value = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
});
const onMouseEnter = () => {
if (triggerRef.value) {
if (triggerRef.value && hasCursor.value) {
show(triggerRef.value, props.hotkey);
}
};
+9 -10
View File
@@ -1,18 +1,14 @@
<script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue';
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const { activeElement, activeHotkey, isVisible } = useTooltip();
watch(activeElement, () => {
console.log('activeElement', activeElement.value);
});
const tooltipRef = ref<HTMLElement | null>(null);
const { floatingStyles, placement } = useFloating(activeElement, tooltipRef, {
const { floatingStyles, placement, middlewareData } = useFloating(activeElement, tooltipRef, {
placement: 'top',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 8 })],
middleware: [offset(6), flip(), shift({ padding: 8 }), hide()],
transform: false,
});
@@ -29,8 +25,8 @@ watch(activeElement, (newEl, oldEl) => {
});
// Also reset isMoving when the tooltip fully closes
watch(isVisible, (visible) => {
if (!visible) isMoving.value = false;
watch([isVisible, middlewareData], ([visible, middlewareData]) => {
if (!visible || middlewareData.hide?.referenceHidden) isMoving.value = false;
});
const isMac = import.meta.client ? navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 : false;
@@ -58,7 +54,10 @@ const transformOrigin = computed(() => placement.value.includes('top') ? 'transf
? 'top, left, right, bottom, opacity, transform'
: 'opacity, transform',
transitionDuration: '150ms',
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)'
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)',
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}
]" :class="transformOrigin"
class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs">
+196 -58
View File
@@ -1,75 +1,213 @@
import type { Entity } from "@triplit/client";
import type schema from "#triplit/schema";
import { nanoid } from "nanoid";
import { assert } from "~~/utils/assert";
import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema';
export type Agent = Readonly<Entity<typeof schema, 'agents'> & { topics: Readonly<Entity<typeof schema, 'topics'>>[] }>;
export type Topic = typeof schema.topics.$inferSelect;
export type Agent = typeof schema.agents.$inferSelect;
export type AgentWithTopics = Agent & { topics: Topic[] };
export const useAgents = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
export const useAgents = async () => {
const agents = useState<AgentWithTopics[]>('agents_state', () => []);
const loaded = useState('agents_loaded', () => false);
// dont leaking between different users/requests
if (!nuxtApp._agentsState) {
nuxtApp._agentsState = {
list: ref<Agent[]>([]),
initPromise: null as Promise<void> | null,
};
}
const { refresh } = await useFetch<AgentWithTopics[]>('/api/agents', {
key: 'agents_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok) {
agents.value = response._data ?? [];
}
}
});
const state = nuxtApp._agentsState as {
list: Ref<Agent[]>;
initPromise: Promise<void> | null;
};
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
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) => {
if (newAgents && newAgents.length > 0) {
state.list.value = newAgents as unknown as Agent[];
}
}, { immediate: true, flush: 'sync' });
})();
return state.initPromise;
};
const getAgent = (id: MaybeRef<string>) => {
return computed(() => state.list.value.find((agent) => agent.id === toRef(id).value) || null);
}
const createAgent = async () => {
const triplit = useTriplitClient();
const createAgent = async (navigate: boolean = true) => {
const { user } = useAuth();
if (!user.value) throw new Error('No user');
if (!user.value) {
console.error('No user');
return null;
}
const id = nanoid();
await triplit.insert('agents', {
id,
name: 'New Agent',
const agentId = nanoid();
const agent = {
id: agentId,
userId: user.value.id,
name: 'New Agent',
systemPrompt: 'You are a helpful assistant.',
defaultModelId: null,
imageUrl: null,
createdAt: new Date().toISOString(),
} as AgentWithTopics;
const router = useRouter()
// @ts-ignore - stack depth
const res = await attempt($fetch('/api/agent', {
method: 'POST',
body: agent,
onRequest() {
agents.value = [...(agents.value), { ...agent, topics: [] as Topic[], createdAt: new Date() }];
if (navigate) {
router.push(`/agent/${agentId}`);
}
},
onRequestError() {
if (navigate) {
const route = useRoute();
if (route.params.id === agentId) {
router.push('/');
}
}
agents.value = agents.value.filter(a => a.id !== agentId);
},
onResponseError() {
if (navigate) {
const route = useRoute();
if (route.params.id === agentId) {
router.push('/');
}
}
agents.value = agents.value.filter(a => a.id !== agentId);
},
async onResponse() {
await refresh();
}
}));
if (!res.ok) return null;
return agent;
};
const patchAgentLocally = (id: string, updates: Partial<Agent>) => {
if (!agents.value) return null;
agents.value = agents.value.map(a =>
a.id === id ? { ...a, ...updates } : a
);
};
const patchTopicLocally = (id: string, updates: Partial<Topic>) => {
if (!agents.value) return null;
agents.value = agents.value.map(a =>
a.topics.find(t => t.id === id) ? {
...a, topics: a.topics.map(t =>
t.id === id ? { ...t, ...updates } : t
)
} : a
);
};
const updateAgent = async (id: string, updates: Partial<Agent>) => {
const agent = agents.value.find(a => a.id === id);
if (!agent) return;
await $fetch(`/api/agent/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
agents.value = agents.value.map(a =>
a.id === id ? { ...a, ...updates } : a
);
},
onRequestError() {
agents.value = agents.value.map(a =>
a.id === id ? agent : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === id ? agent : a
);
},
async onResponse() {
await refresh();
}
});
}
const getAgent = (id: MaybeRef<string>) => {
return computed(() => agents.value?.find((agent) => agent.id === unref(id)) || null);
}
const deleteAgent = async (id: string) => {
let agent = agents.value.find(a => a.id === id);
if (!agent) return;
await $fetch(`/api/agent/${id}`, {
method: 'DELETE',
onRequest() {
agents.value = agents.value.filter(a => a.id !== id);
},
onResponseError() {
agents.value = [...agents.value.filter(a => a.id !== id), agent];
},
async onResponse() {
await refresh();
}
});
}
// TODO: since topics are contained within each agent struct, all topic
// actions must be done through the agents composable
const createTopic = async (agentId: string) => {
const topicId = nanoid();
const topic = {
id: topicId,
name: 'New Topic',
agentId,
};
await $fetch(`/api/topic`, {
method: 'POST',
body: topic,
onRequest() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: [{ ...topic, createdAt: new Date() }, ...a.topics] } : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: a.topics.filter(t => t.id !== topicId) } : a
);
},
});
assert('flush' in triplit);
await triplit.flush();
return topic;
}
const deleteTopic = async (agentId: string, topicId: string) => {
const targetTopic = agents.value.flatMap(agent => agent.topics).find(topic => topic.id === topicId);
if (!targetTopic) return;
await $fetch(`/api/topic/${topicId}`, {
method: 'DELETE',
onRequest() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: a.topics.filter(t => t.id !== topicId) } : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: [...a.topics.filter(t => t.id !== topicId), targetTopic] } : a
);
},
});
}
return state.list.value.find((agent) => agent.id === id)!;
};
return {
init,
agents: state.list,
agents,
refresh,
createAgent,
createTopic,
getAgent,
createAgent
patchAgentLocally,
patchTopicLocally,
updateAgent,
deleteAgent,
deleteTopic
};
};
}
+8 -33
View File
@@ -30,11 +30,9 @@ export const useAuth = () => {
return sessionPromise;
}
let finish: (value: Result<sessionData, AuthError>) => void;
sessionFetching.value = true;
sessionPromise = new Promise(async (resolve, reject) => {
finish = resolve;
});
const { promise, resolve } = Promise.withResolvers<Result<sessionData, AuthError>>();
sessionPromise = promise;
let data: {
session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>;
@@ -52,18 +50,17 @@ export const useAuth = () => {
} else {
data = (await authClient.getSession()).data;
}
session.value = data?.session || null;
user.value = data?.user || null;
resolve(Ok({ session: data?.session || null, user: data?.user || null }));
} catch (error) {
console.error('Failed to fetch session:', error);
resolve(Err(AuthError.NetworkError));
} finally {
sessionFetching.value = false;
finish!(Err(AuthError.NetworkError));
return sessionPromise;
}
session.value = data?.session || null;
user.value = data?.user || null;
sessionFetching.value = false;
sessionFetching.value = false;
finish!(Ok({ session: data?.session || null, user: data?.user || null }));
return sessionPromise;
};
@@ -71,15 +68,6 @@ export const useAuth = () => {
authClient.$store.listen('$sessionSignal', async (signal) => {
if (!signal) return;
await fetchSession();
if (!session.value) return;
const triplit = useTriplitClient();
if ('updateOptions' in triplit) {
triplit.updateOptions({
token: session.value.token,
});
}
});
}
@@ -106,11 +94,6 @@ export const useAuth = () => {
user.value = data.user;
const triplit = useTriplitClient();
if ('startSession' in triplit && data.token) {
await triplit.startSession(data.token);
}
clearNuxtData();
return Ok({ user: data.user, token: data.token });
@@ -147,10 +130,6 @@ export const useAuth = () => {
user.value = data.user;
const triplit = useTriplitClient();
assert('startSession' in triplit);
await triplit.startSession(data.token);
clearNuxtData();
return Ok({ user: data.user, token: data.token });
@@ -176,10 +155,6 @@ export const useAuth = () => {
user.value = null;
session.value = null;
const triplit = useTriplitClient();
assert('disconnect' in triplit);
triplit.disconnect();
clearNuxtData();
return Ok(undefined);
+341 -419
View File
@@ -1,33 +1,27 @@
import type schema from "#triplit/schema";
import type { Entity } from "@triplit/client";
import type { FilePart, ImagePart, ModelMessage } from "ai";
import { nanoid } from "nanoid";
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
import { type Result, Ok, Err, attempt } from "~~/types/result";
import { assert } from "~~/utils/assert";
import { nanoid } from 'nanoid';
import * as schema from '~~/drizzle/schema';
import { Err, Ok, type Result } from '~~/types/result';
import { buildMessageTree } from '~~/utils/message';
export type BaseMessage = {
content: string;
fileIds: string[];
}
export type MessageEntity = Entity<typeof schema, 'messages'> & {
parts: (Entity<typeof schema, 'message_parts'> & {
toolCall: Entity<typeof schema, 'tool_calls'> | null
})[] | undefined
} & { generation: Entity<typeof schema, 'generations'> | null }
& { attachments: Entity<typeof schema, 'attachments'>[] }
export type ToolCall = typeof schema.toolCalls.$inferSelect
export type Message =
MessageEntity & {
children: (MessageEntity | undefined)[];
}
export type MessagePart = typeof schema.messageParts.$inferSelect & { toolCall: ToolCall | null }
export type MessageEntity = typeof schema.messages.$inferSelect & { parts: MessagePart[] | undefined } & { generation: typeof schema.generations.$inferSelect | null } & { attachments: (typeof schema.attachments.$inferSelect & { file: typeof schema.files.$inferSelect })[] }
export type Message = MessageEntity & { children: (MessageEntity | undefined)[] }
export enum ChatErrorType {
NoModel = 0,
NoProvider,
NoAgent,
NoUser,
NoTopic,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
GenerationFailed,
@@ -37,315 +31,347 @@ export enum ChatErrorType {
Unimplemented,
}
export const useChat = (agentId: string) => {
const triplit = useTriplitClient();
type Event = { type: string; payload: any; timestamp: number };
const createTopic = async () => {
const { user } = useAuth();
if (!user.value) {
console.error('No user');
const TARGET_UPDATES_PER_SECOND = 24;
export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
let data = useState<Topic & { messages: Message[] } | undefined>('useChat:data', () => undefined);
let sse: EventSource | undefined;
const textDeltaBuffer: Map<string, Map<string, string>> = new Map();
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
let flushCbs: Set<() => void> = new Set();
const flushTextDeltas = () => {
if (!data.value) return;
for (const [messageId, parts] of textDeltaBuffer) {
const msg = data.value.messages.find(m => m.id === messageId);
if (!msg) continue;
for (const [partId, content] of parts) {
console.log("content", content);
const part = msg.parts?.find(p => p.id === partId);
if (part) {
part.content += content;
}
}
}
textDeltaBuffer.clear();
flushTimeout = undefined;
for (const cb of flushCbs) {
cb();
}
flushCbs.clear();
};
const nextFlush = (cb: () => void) => {
if (flushTimeout) {
flushCbs.add(cb);
return;
}
const newTopic = await triplit.insert('topics', {
name: 'New Topic',
userId: user.value.id,
agentId,
createdAt: new Date().toISOString(),
});
cb();
}
assert('flush' in triplit);
await triplit.flush();
return newTopic;
const scheduleFlush = () => {
if (flushTimeout) return;
flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND);
};
const marshallMessages = (agent: Entity<typeof schema, 'agents'>, messages: Readonly<MessageEntity[]>): Result<ModelMessage[], string> => {
const marshalledMessages: ModelMessage[] = [];
const processEvent = (event: Event) => {
if (!data.value) return;
if (agent && agent.systemPrompt) {
marshalledMessages.push({
role: 'system',
content: agent.systemPrompt,
});
const { type, payload } = event;
switch (type) {
case 'MESSAGE_CREATED': {
// Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE)
if (!data.value.messages.find(m => m.id === payload.id)) {
data.value.messages.push({ ...payload, parts: [] });
} else {
// replace the existing data with the new data
const index = data.value.messages.findIndex(m => m.id === payload.id);
if (index !== -1) {
data.value.messages[index] = { ...payload, parts: [] };
}
}
break;
}
case 'MESSAGE_UPDATED': {
const msgIndex = data.value.messages.findIndex(m => m.id === payload.id);
if (msgIndex !== -1) {
data.value.messages[msgIndex] = { ...data.value.messages[msgIndex], ...payload };
}
break;
}
case 'MESSAGE_DELETED': {
data.value.messages = data.value.messages.filter(m => m.id !== payload.id);
break;
}
case 'text-start': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
}
break;
}
case 'text-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (!msg) break;
if (!textDeltaBuffer.has(payload.messageId)) {
textDeltaBuffer.set(payload.messageId, new Map());
}
const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
const existing = messageBuffer.get(payload.partId) || '';
messageBuffer.set(payload.partId, existing + payload.content);
console.log("messageBuffer", messageBuffer, existing + payload.content);
scheduleFlush();
break;
}
case 'text-end': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.id === payload.partId);
if (part) {
nextFlush(() => {
part.content = payload.content;
part.finished = true;
});
}
break;
}
case 'tool-call-start': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
}
break;
}
case 'tool-call-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.toolCallId === payload.toolCallId);
if (part?.toolCall) {
if (payload.input) part.toolCall.input = payload.input;
if (payload.output) part.toolCall.output = payload.output;
if (payload.error) part.toolCall.error = payload.error;
if (payload.status) part.toolCall.status = payload.status;
}
break;
}
case 'generation-complete': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg && msg.generation) {
msg.generation.status = 'completed';
msg.generation.tokens = payload.tokens;
}
break;
}
case 'generation-failed': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg && msg.generation) {
msg.generation.status = 'failed';
msg.generation.error = payload.error;
}
break;
}
case 'topic_updated': {
data.value = { ...data.value, ...payload };
break;
}
}
}
const connectSSE = async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const id = unref(topicId);
const lastUpdate = data.value?.messages.at(-1)?.updatedAt || '0';
const msgCount = data.value?.messages.length || 0;
if (sse && sse.readyState !== EventSource.CLOSED) {
if (!(new URL(sse.url).pathname.startsWith(`/api/topic/${id}`))) {
sse.close();
} else {
return;
}
}
messages.forEach((message) => {
switch (message.role) {
case 'user':
const attachments = message.attachments.map(attachment => {
if (attachment.mimeType.startsWith('image/')) {
return {
type: 'image',
image: attachment.url,
};
}
sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`);
return {
type: 'file',
data: attachment.url,
filename: attachment.name,
mediaType: attachment.mimeType,
};
}) as (FilePart | ImagePart)[];
sse.addEventListener("error", (e) => {
console.error('sse error', e);
setTimeout(connectSSE, 1500);
})
marshalledMessages.push({
role: 'user',
// TODO: when we have images or files, this is where we need to handle them
content: [
{
type: 'text',
text: message.content
},
...attachments,
],
});
break;
case 'assistant':
(message.parts || []).forEach((part) => {
if (!part) return Err('Part is undefined')
sse.addEventListener("message", (e) => {
const raw = JSON.parse(e.data);
const events: Event[] = Array.isArray(raw) ? raw : [raw];
switch (part.type) {
case 'text':
case 'reasoning': {
marshalledMessages.push({
role: 'assistant',
content: part.content,
});
break;
}
case 'tool-call': {
if (part.toolCall === null) return Err('Tool call is null')
for (const { type, payload, timestamp } of events) {
if (!type || !payload) continue;
if (part.toolCall.status === 'pending') {
return Err('Marshalling tool call that is still pending. This is likely a UI bug if this happens.')
}
if (type === 'initial_state') {
data.value = payload;
resolve()
continue;
}
let inputValue: string = '';
switch (part.toolCall.input!.type) {
case 'text':
inputValue = part.toolCall.input!.value;
break;
case 'json':
inputValue = JSON.parse(part.toolCall.input!.value);
break;
}
marshalledMessages.push({
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
input: inputValue,
},
],
providerOptions: part.providerOptions,
});
if (part.toolCall.status === 'failed') {
let failureType: 'error-text' | 'error-json';
let failureValue: string;
if (part.toolCall.error === null || part.toolCall.error === undefined) {
failureType = 'error-text';
failureValue = 'An unknown error occurred';
} else {
switch (part.toolCall.error!.type) {
case 'text':
failureType = 'error-text';
failureValue = part.toolCall.error!.value;
break;
case 'json':
failureType = 'error-json';
failureValue = JSON.stringify(part.toolCall.error!.value, null, 2);
break;
}
}
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: failureType,
value: failureValue,
},
},
],
providerOptions: part.providerOptions,
});
break;
}
if (part.toolCall.status === 'completed') {
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: 'json',
value: JSON.stringify(part.toolCall.output!.value, null, 2),
},
},
],
providerOptions: part.providerOptions,
});
break;
}
} break;
default:
return Err(`Unknown part type: ${part.type}`)
}
});
break;
default:
return Err(`Unknown message role: ${message.role}`)
processEvent({ type, payload, timestamp });
}
});
return Ok(marshalledMessages);
};
await promise;
}
const startGeneration = async (
messages: ModelMessage[],
args: Record<string, any>,
topic: Entity<typeof schema, 'topics'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>,
parentMessageId: string | null = null
): Promise<Result<void, ChatErrorType>> => {
let providerApiKey: string | undefined = undefined;
if (provider.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(provider.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
if (connect) {
onBeforeUnmount(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTextDeltas();
}
if (sse) {
console.log('[SSE] Closing connection');
sse.close();
sse = undefined;
}
});
if (import.meta.server) {
const id = unref(topicId);
const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
data.value = ssrData.value;
} else {
// if (data.value === undefined) {
// const id = unref(topicId);
// const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
// data.value = ssrData.value;
// }
await connectSSE();
}
}
const topic = computed(() => ({
...data.value,
messages: buildMessageTree(data.value?.messages || [])
}));
const getProviderAPIKey = async (provider: Provider): Promise<Result<string | undefined, ChatErrorType>> => {
if (provider.config.apiKey === undefined) {
return Ok(undefined);
}
try {
await $fetch('/api/chat/generate', {
method: 'POST',
body: {
messages,
topicId: topic.id,
parentMessageId,
model: {
providerId: provider.id,
modelId: model.id,
args,
},
providerApiKey: providerApiKey,
},
headers: {
'Content-Type': 'application/json',
},
});
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
return Ok(undefined);
return Ok(await decrypt(
key,
base64ToUint8Array(provider.config.apiKey)
));
} catch (error) {
console.error('Failed to generate:', error);
return Err(ChatErrorType.GenerationFailed);
console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
}
}
const sendMessage = async (
message: BaseMessage,
topic: Entity<typeof schema, 'topics'>,
topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>,
baseMessage: BaseMessage,
onRequest?: () => void,
): Promise<Result<void, ChatErrorType>> => {
console.log("sendMessage", baseMessage);
const { user } = useAuth();
if (!user.value) {
console.error('No user');
return Err(ChatErrorType.NoUser);
}
if (!user.value) return Err(ChatErrorType.NoUser);
const messageId = nanoid();
const attachmentsPromise = message.fileIds.map(async fileId => {
const file = await triplit.fetchOne(triplit.query('files').Where('id', '=', fileId));
assert(file !== null);
return await triplit.insert('attachments', {
userId: user.value!.id,
topicId: topic.id,
messageId: messageId,
fileId: fileId,
name: file.name,
mimeType: file.mimeType,
url: file.url,
createdAt: file.createdAt,
})!;
});
const newMessage = await triplit.insert('messages', {
id: messageId,
userId: user.value.id,
topicId: topic.id,
createdAt: new Date().toISOString(),
content: message.content,
role: 'user',
}).catch(async error => {
console.error('Failed to insert message:', error);
await triplit.delete('messages', messageId);
return Err(ChatErrorType.DatabaseOperationFailed);
}) as Message;
const attachments = await Promise.all(attachmentsPromise);
newMessage.attachments = attachments;
const messages = marshallMessages(
agent,
topicMessages.concat(newMessage)
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
const args = {
temperature: 1,
max_tokens: 100,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
};
return startGeneration(messages.data, args, topic, provider, model).then(async res => {
if (res.ok === false) {
console.error('Failed to start generation:', res.error);
await triplit.delete('messages', messageId);
try {
const message = {
id: nanoid(),
role: 'user',
content: baseMessage.content,
fileIds: baseMessage.fileIds,
}
return res;
});
console.log("sendMessage", message, baseMessage.content, baseMessage.fileIds);
await $fetch(`/api/topic/${unref(topicId)}/message`, {
method: 'POST',
body: {
message
},
onRequest() {
if (data.value) {
data.value!.messages.push({
topicId: unref(topicId),
userId: user.value!.id,
// TODO
attachments: [],
parts: undefined,
generation: null,
parentMessageId: null,
generationId: null,
focusedIndex: null,
deleted: null,
createdAt: new Date(),
updatedAt: new Date(),
children: [],
...message
} as Message);
}
onRequest?.();
},
onResponseError() {
data.value!.messages = data.value!.messages.filter(m => m.id !== message.id);
},
});
return Ok(undefined);
} catch (error) {
console.error('Failed to send message:', error);
return Err(ChatErrorType.GenerationFailed);
}
};
const startGeneration = async (model: ModelWithProvider) => {
console.log(model.provider);
const providerApiKeyRes = await getProviderAPIKey(model.provider);
if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
}
const providerApiKey = providerApiKeyRes.data;
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
},
});
}
/**
*
* @param messageId The ID of the message we wish to regenerate *for*, this
@@ -364,11 +390,8 @@ export const useChat = (agentId: string) => {
*/
const regenerateMessage = async (
messageId: string,
topic: Entity<typeof schema, 'topics'>,
topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>
model: ModelWithProvider
): Promise<Result<void, ChatErrorType>> => {
const targetMessage = topicMessages.find(message => message.id === messageId);
if (!targetMessage) {
@@ -376,16 +399,14 @@ export const useChat = (agentId: string) => {
}
let targetMessageIndex = topicMessages.indexOf(targetMessage);
const args = {
temperature: 1,
max_tokens: 100,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
};
const providerApiKeyRes = await getProviderAPIKey(model.provider);
if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
}
const providerApiKey = providerApiKeyRes.data;
let parentMessageId = null;
let focusedMessages;
let parentMessageId = undefined;
let focusedMessages: MessageEntity[] | undefined;
if (targetMessage.role === 'user') {
// we need to find the next agent message
while (targetMessageIndex < topicMessages.length) {
@@ -408,130 +429,31 @@ export const useChat = (agentId: string) => {
focusedMessages = topicMessages;
}
if (parentMessageId === null) {
const messages = marshallMessages(
agent,
topicMessages
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
return startGeneration(messages.data, args, topic, provider, model);
}
const focusedMessageIndex = topicMessages.findIndex(m => m.id === parentMessageId);
if (focusedMessageIndex !== topicMessages.length - 1) {
}
const messages = marshallMessages(
agent,
focusedMessages
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
}
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = useUserSettings();
console.log(settings.value);
if (!settings.value.systemAssistants.rename.enabled) {
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return Err(AutoRenameError.NoModelSelected);
}
const modelResult = await attempt(triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider')));
if (modelResult.ok === false) {
return Err(AutoRenameError.DatabaseOperationFailed);
}
const model = modelResult.data;
if (!model) {
return Err(AutoRenameError.NoModelFound);
}
if (model.enabled === false || model.provider?.enabled === false) {
return Err(AutoRenameError.ModelDisabled)
}
let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(model.provider!.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
}
}
await triplit.update('topics', topicId, {
renaming: true
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
parentMessageId,
},
});
try {
// @ts-ignore - excessive stack depth
const res = await $fetch(`/api/auto-rename/${topicId}`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
prompt,
providerApiKey,
}),
}) as { ok: true, renameId: string } | { ok: false, code: string };
if (!res.ok) {
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(undefined);
}
return Ok(res.renameId);
} catch (error) {
triplit.update('topics', topicId, {
renaming: false,
});
const patchMessageLocally = (id: string, updates: Partial<Message>) => {
if (!data.value) return;
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
data.value.messages = data.value.messages.map(m =>
m.id === id ? { ...m, ...updates } : m
);
}
return {
topic,
sendMessage,
AutoRenameError,
autoRename,
startGeneration,
regenerateMessage,
createTopic,
patchMessageLocally
};
}
}
+143 -47
View File
@@ -1,54 +1,37 @@
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import { nanoid } from 'nanoid';
import * as schema from '~~/drizzle/schema';
type Provider = Entity<typeof schema, 'providers'>;
type Model = Entity<typeof schema, 'models'>;
export type Model = typeof schema.models.$inferSelect;
export type Provider = typeof schema.providers.$inferSelect;
export interface ModelWithProvider extends Model {
provider: Provider;
}
export interface ProviderWithModels extends Provider {
export type ProviderWithModels = Provider & {
models: Model[];
}
};
export const useModels = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
export type ModelWithProvider = Model & {
provider: Provider;
};
if (!nuxtApp._modelsState) {
nuxtApp._modelsState = {
providers: shallowRef([]),
isReady: ref(false)
};
}
export const useModels = async () => {
const providers = useState<ProviderWithModels[]>('models_state', () => []);
const loaded = useState('models_loaded', () => false);
const state = nuxtApp._modelsState as {
providers: Ref<ProviderWithModels[]>;
initPromise: Promise<void> | null;
};
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const query = triplit.query('providers').Include('models');
const { results } = await useQuery('providers', triplit, query)
watch(results, (newProviders) => {
if (newProviders && newProviders.length > 0) {
state.providers.value = newProviders as unknown as ProviderWithModels[];
}
}, { immediate: true, flush: 'sync' });
})();
return state.initPromise;
};
const { refresh } = await useFetch<ProviderWithModels[]>('/api/providers', {
key: 'models_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok) {
providers.value = response._data ?? [];
}
}
});
const allModels = computed<ModelWithProvider[]>(() => {
const result: ModelWithProvider[] = [];
for (const provider of state.providers.value) {
for (const provider of providers.value) {
if (!provider.enabled) continue;
for (const model of provider.models || []) {
if (!model.enabled) continue;
@@ -63,19 +46,132 @@ export const useModels = () => {
};
const getProvider = (id: string): ProviderWithModels | undefined => {
return state.providers.value.find((provider) => provider.id === id);
return providers.value.find((provider) => provider.id === id);
};
const getFirstAvailableModel = () => {
return allModels.value[0] ?? null;
};
const createModel = async (model: Model) => {
model.id = nanoid();
await $fetch(`/api/model`, {
method: 'POST',
body: model,
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.id === model.providerId ? [...p.models, model] : p.models
}));
},
onResponseError() {
providers.value = providers.value.map(p => ({
...p,
models: p.id === model.providerId ? p.models.filter(m => m.id !== model.id) : p.models
}));
},
});
}
const updateModel = async (id: string, updates: Partial<Model>) => {
const model = providers.value.find(p => p.models.find(m => m.id === id));
if (!model) return;
const original = model;
await $fetch(`/api/model/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? { ...m, ...updates } : m
)
}));
},
onRequestError() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? original : m
)
}) as ProviderWithModels);
},
onResponseError() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? original : m
)
}) as ProviderWithModels);
},
// async onResponse() {
// await refresh();
// }
});
};
const deleteModel = async (id: string) => {
const model = providers.value.find(p => p.models.find(m => m.id === id));
if (!model) return;
await $fetch(`/api/model/${id}`, {
method: 'DELETE',
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.filter(m => m.id !== id)
}));
},
onResponseError() {
providers.value.push(model);
},
async onResponse() {
await refresh();
}
});
}
const updateProvider = async (id: string, updates: Partial<Provider>) => {
const provider = providers.value.find(p => p.id === id);
if (!provider) return;
const original = provider;
await $fetch(`/api/provider/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
providers.value = providers.value.map(p =>
p.id === id ? { ...p, ...updates } : p
);
},
onRequestError() {
providers.value = providers.value.map(p =>
p.id === id ? original : p
);
},
onResponseError() {
providers.value = providers.value.map(p =>
p.id === id ? original : p
);
},
async onResponse() {
await refresh();
}
});
};
return {
init,
providers: state.providers,
providers,
allModels,
getModel,
getProvider,
getFirstAvailableModel
getFirstAvailableModel,
createModel,
updateModel,
updateProvider,
deleteModel
};
};
}
+2 -3
View File
@@ -1,7 +1,6 @@
import type { Entity } from '@triplit/client';
import type { schema } from '#triplit/schema';
import * as schema from '~~/drizzle/schema';
type Generation = Entity<typeof schema, 'generations'>;
type Generation = typeof schema.generations.$inferSelect;
export const useTokenDropdown = () => {
const isOpen = useState('token-dropdown:open', () => false);
+101
View File
@@ -0,0 +1,101 @@
import { Err, Ok, type Result } from "~~/types/result";
export const useTopic = () => {
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = await useUserSettings();
if (!settings.value.systemAssistants.rename.enabled) {
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return Err(AutoRenameError.NoModelSelected);
}
const { allModels } = await useModels();
const model = allModels.value.find(model => model.id === settings.value.systemAssistants.rename.modelId);
if (!model) {
return Err(AutoRenameError.NoModelFound);
}
if (model.enabled === false || model.provider?.enabled === false) {
return Err(AutoRenameError.ModelDisabled)
}
let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(model.provider!.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
}
}
const { agents } = await useAgents();
try {
// @ts-ignore - excessive stack depth
const res = await $fetch(`/api/topic/${topicId}/auto-rename`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
providerApiKey,
}),
onRequest() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
});
}
},
onRequestError() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
});
}
},
}) as { ok: true, renameId: string } | { ok: false, code: string };
if (!res.ok) {
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(res.renameId);
} catch (error) {
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
}
return {
autoRename
};
}
+190
View File
@@ -0,0 +1,190 @@
export const useUserEvents = () => {
const eventSource = ref<EventSource | null>(null);
const isConnected = useState('userEvents:connected', () => false);
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
const reconnectAttempts = ref(0);
const connectionStartTime = ref<number>(0);
const pendingReconnect = ref<NodeJS.Timeout | null>(null);
const BASE_DELAY = 1000;
const MAX_DELAY = 30000;
const MAX_RECONNECT_ATTEMPTS = 10;
const cleanup = () => {
if (pendingReconnect.value) {
clearTimeout(pendingReconnect.value);
}
if (eventSource.value) {
eventSource.value.close();
eventSource.value = null;
}
isConnected.value = false;
};
const getReconnectDelay = (attempt: number): number => {
const exponentialDelay = Math.min(
BASE_DELAY * Math.pow(2, attempt),
MAX_DELAY
);
const jitter = exponentialDelay * Math.random() * 0.25;
return exponentialDelay + jitter;
};
const scheduleReconnect = () => {
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached');
return;
}
const delay = getReconnectDelay(reconnectAttempts.value);
console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts.value + 1})`);
pendingReconnect.value = setTimeout(() => {
reconnectAttempts.value++;
connect();
}, delay);
};
const connect = async () => {
cleanup();
connectionStartTime.value = Date.now();
reconnectAttempts.value = 0;
const source = new EventSource('/api/events');
eventSource.value = source;
source.onopen = () => {
console.log('User events connected');
isConnected.value = true;
reconnectAttempts.value = 0;
};
source.onerror = () => {
isConnected.value = false;
if (source.readyState === EventSource.CLOSED) {
console.log('EventSource closed, scheduling reconnect');
scheduleReconnect();
}
};
source.addEventListener('message', async (event) => {
if (event.data === '') return;
try {
const data = JSON.parse(event.data);
if (data.timestamp && data.timestamp > lastEventTimestamp.value) {
lastEventTimestamp.value = data.timestamp;
}
handleUserEvent(data);
} catch (e) {
console.error('Failed to parse user event:', e);
}
});
};
const handleUserEvent = async (data: { entity: string; op: string; payload: any; timestamp?: number }) => {
const { agents } = await useAgents();
switch (data.entity) {
case 'topics': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.agentId);
if (existing) {
const topicExists = existing.topics.some(t => t.id === data.payload.id);
if (!topicExists) {
agents.value = agents.value.map(agent => {
return agent.id === data.payload.agentId
? { ...agent, topics: [data.payload, ...agent.topics] }
: agent;
});
}
}
break;
}
case 'update': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...t, ...data.payload } : t) }
: agent;
});
}
break;
}
case 'delete': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.filter(t => t.id !== topic.id) }
: agent;
});
}
break;
}
}
break;
}
case 'agents': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.id);
if (existing) {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
} else {
agents.value = [...agents.value, data.payload];
}
break;
}
case 'update': {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
break;
}
case 'delete': {
agents.value = agents.value.filter(agent => agent.id !== data.payload.id);
break;
}
}
break;
}
}
};
const reconcile = async () => {
console.log('Reconciling user state...');
const { data } = await useFetch<AgentWithTopics[]>('/api/agents');
if (data.value) {
const { agents } = await useAgents();
agents.value = data.value;
}
};
onMounted(() => {
connect();
});
onBeforeUnmount(() => {
cleanup();
});
return {
eventSource,
isConnected,
connect,
cleanup,
reconcile,
};
};
+105 -172
View File
@@ -1,197 +1,130 @@
import { schema } from '#triplit/schema';
import { type Entity } from '@triplit/client';
import { computed, watch, ref, type Ref } from 'vue';
import * as schema from '~~/drizzle/schema';
type UserSettings = Entity<typeof schema, 'settings'>;
type UserSettings = typeof schema.settings.$inferSelect;
type Appearance = UserSettings['appearance'];
type SystemAssistants = UserSettings['systemAssistants'];
interface UserSettingsState {
accent: Ref<string>;
neutral: Ref<string>;
hinting: Ref<string>;
colorSchemePreference: Ref<'light' | 'dark' | 'system'>;
colorSchemeClass: Ref<'light' | 'dark' | undefined>;
remoteSettings: Ref<UserSettings | null>;
initPromise: Promise<void> | null;
}
const defaultAppearance: Appearance = {
colorScheme: 'system',
accent: 'violet',
neutral: 'zinc',
hinting: 0,
fontSize: 'md',
};
export const useUserSettings = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
const { user, loggedIn } = useAuth();
export const useUserSettings = async () => {
const settings = useState<UserSettings | null>('settings_state', () => null);
const loaded = useState('settings_loaded', () => false);
const syncing = useState('settings_syncing', () => false);
if (!nuxtApp._userSettingsState) {
nuxtApp._userSettingsState = {
accent: ref('violet'),
neutral: ref('zinc'),
hinting: ref('0'),
colorSchemePreference: ref<'light' | 'dark' | 'system'>('system'),
colorSchemeClass: ref<undefined | 'light' | 'dark'>(undefined),
remoteSettings: ref<UserSettings | null>(null),
initPromise: null,
} as UserSettingsState;
}
const localAppearance = useState<Appearance | null>('settings_local_appearance', () => null);
const localSystemAssistants = useState<SystemAssistants | null>('settings_local_sa', () => null);
const state = nuxtApp._userSettingsState as UserSettingsState;
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const { results } = await useQuery('settings', triplit, triplit.query('settings'));
watch(results, (val) => {
if (val && val.length > 0) {
state.remoteSettings.value = val[0] as UserSettings;
} else {
state.remoteSettings.value = null;
}
}, { immediate: true, deep: true });
watch(state.remoteSettings, (newSettings) => {
if (!newSettings?.appearance) return;
const { appearance } = newSettings;
if (appearance.colorScheme) {
state.colorSchemePreference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
}
if (appearance.accent) {
state.accent.value = appearance.accent;
}
if (appearance.neutral) {
state.neutral.value = appearance.neutral;
}
if (appearance.hinting !== undefined) {
state.hinting.value = String(appearance.hinting);
}
}, { immediate: true, deep: true });
if (import.meta.client) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => {
if (state.colorSchemePreference.value === 'system') {
state.colorSchemeClass.value = mediaQuery.matches ? 'dark' : 'light';
} else {
state.colorSchemeClass.value = state.colorSchemePreference.value as 'dark' | 'light';
}
};
mediaQuery.addEventListener('change', update);
watch(state.colorSchemePreference, update, { immediate: true });
} else {
watch(state.colorSchemePreference, (pref) => {
state.colorSchemeClass.value = pref === 'system' ? 'dark' : (pref as 'dark' | 'light');
}, { immediate: true });
const { refresh } = await useFetch<UserSettings>('/api/settings', {
key: 'settings_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok && response._data) {
settings.value = response._data;
localAppearance.value = { ...defaultAppearance, ...response._data.appearance };
localSystemAssistants.value = response._data.systemAssistants;
}
})();
}
});
return state.initPromise;
const applyOptimisticUpdates = (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
if (updates.appearance && localAppearance.value) {
localAppearance.value = { ...localAppearance.value, ...updates.appearance };
}
if (updates.systemAssistants && localSystemAssistants.value) {
localSystemAssistants.value = { ...localSystemAssistants.value, ...updates.systemAssistants };
}
if (updates.appearance && settings.value) {
settings.value = {
...settings.value,
appearance: { ...settings.value.appearance, ...updates.appearance }
};
}
if (updates.systemAssistants && settings.value) {
settings.value = {
...settings.value,
systemAssistants: { ...settings.value.systemAssistants, ...updates.systemAssistants }
};
}
};
const updateSettings = async (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
const original = settings.value;
const originalAppearance = localAppearance.value;
const originalSA = localSystemAssistants.value;
applyOptimisticUpdates(updates);
try {
syncing.value = true;
await $fetch(`/api/settings`, {
method: 'PATCH',
body: updates,
});
await refresh();
} catch (error) {
settings.value = original;
localAppearance.value = originalAppearance;
localSystemAssistants.value = originalSA;
throw error;
} finally {
syncing.value = false;
}
};
const effectiveAppearance = computed(() => {
return localAppearance.value || defaultAppearance;
});
const effectiveSystemAssistants = computed(() => {
return localSystemAssistants.value || {};
});
const colorSchemeValue = computed(() => {
if (state.colorSchemePreference.value === 'system') {
if (effectiveAppearance.value.colorScheme === 'system') {
if (import.meta.client) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
}
return state.colorSchemePreference.value as 'dark' | 'light';
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
});
const settings = computed(() => {
const remote = state.remoteSettings.value;
return {
appearance: {
colorScheme: remote?.appearance?.colorScheme ?? state.colorSchemePreference.value,
accent: remote?.appearance?.accent ?? state.accent.value,
neutral: remote?.appearance?.neutral ?? state.neutral.value,
hinting: remote?.appearance?.hinting ?? Number(state.hinting.value),
fontSize: remote?.appearance?.fontSize ?? 'medium',
},
systemAssistants: {
rename: {
enabled: remote?.systemAssistants?.rename?.enabled ?? false,
prompt: remote?.systemAssistants?.rename?.prompt ?? null,
modelId: remote?.systemAssistants?.rename?.modelId ?? null,
}
}
};
});
const updateSettings = async (updates: {
appearance?: {
colorScheme?: 'light' | 'dark' | 'system';
accent?: string;
neutral?: string;
hinting?: number;
fontSize?: string;
};
systemAssistants?: {
rename?: {
enabled?: boolean;
prompt?: string | null;
modelId?: string | null;
};
};
}) => {
if (updates.appearance) {
if (updates.appearance.colorScheme) state.colorSchemePreference.value = updates.appearance.colorScheme;
if (updates.appearance.accent) state.accent.value = updates.appearance.accent;
if (updates.appearance.neutral) state.neutral.value = updates.appearance.neutral;
if (updates.appearance.hinting !== undefined) state.hinting.value = String(updates.appearance.hinting);
}
if (!loggedIn.value || !user.value?.id) return;
const current = state.remoteSettings.value;
if (!current) {
await triplit.insert('settings', {
userId: user.value.id,
appearance: {
colorScheme: state.colorSchemePreference.value,
accent: state.accent.value,
neutral: state.neutral.value,
hinting: Number(state.hinting.value),
...(updates.appearance || {})
},
systemAssistants: {
rename: {
enabled: updates.systemAssistants?.rename?.enabled ?? true,
prompt: updates.systemAssistants?.rename?.prompt ?? null,
modelId: updates.systemAssistants?.rename?.modelId ?? null,
}
}
});
return;
}
await triplit.update('settings', current.id, (s) => {
if (updates.appearance) {
s.appearance = {
...(s.appearance || {}),
...updates.appearance
};
}
if (updates.systemAssistants) {
// @ts-expect-error
s.systemAssistants = {
...(s.systemAssistants || {}),
...updates.systemAssistants
};
}
});
};
const effectiveSettings = computed(() => ({
appearance: effectiveAppearance.value,
systemAssistants: effectiveSystemAssistants.value,
}));
return {
init,
settings,
remoteSettings: state.remoteSettings,
settings: effectiveSettings,
rawSettings: readonly(settings),
updateSettings,
accent: state.accent,
neutral: state.neutral,
hinting: state.hinting,
syncing: readonly(syncing),
loaded: readonly(loaded),
refresh,
accent: computed(() => effectiveAppearance.value.accent ?? 'violet'),
neutral: computed(() => effectiveAppearance.value.neutral ?? 'zinc'),
hinting: computed(() => effectiveAppearance.value.hinting ?? 0),
colorScheme: {
preference: state.colorSchemePreference,
preference: computed(() => effectiveAppearance.value.colorScheme ?? 'system'),
value: colorSchemeValue,
class: state.colorSchemeClass
class: computed(() => {
if (effectiveAppearance.value.colorScheme === 'system') {
if (import.meta.client) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
}
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
}),
}
};
};
+13 -7
View File
@@ -1,7 +1,11 @@
<script setup lang="ts">
// TODO: when a user changes the color scheme and then signs in/up, the color
// scheme will not be updated in the user's settings.
const { colorScheme } = useUserSettings();
// const { colorScheme } = useUserSettings();
const colorScheme = {
class: 'dark',
}
useHead({
htmlAttrs: {
@@ -13,12 +17,14 @@ preloadRouteComponents('/');
</script>
<template>
<main
class="p-2 bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<div class="flex-1 overflow-y-auto">
<slot />
</div>
</main>
<div class="p-2 w-full h-full">
<main
class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<div class="flex-1 overflow-y-auto">
<slot />
</div>
</main>
</div>
</template>
<style>
+7 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import Dialog from '~/components/Dialog/index.vue';
const { colorScheme } = useUserSettings();
const { colorScheme } = await useUserSettings();
const { toggle: toggleSidebar } = useSidebar();
const { addShortcut } = useKeyboardShortcuts();
@@ -16,11 +16,16 @@ useHead({
class: colorScheme.class
}
});
if (import.meta.client) {
const { connect } = useUserEvents();
connect();
}
</script>
<template>
<Sidenav />
<div class="p-2 w-full h-full">
<div class="p-2 w-full h-full max-w-full overflow-x-hidden">
<main
class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<div class="flex-1 overflow-y-auto">
+19 -22
View File
@@ -2,16 +2,14 @@
import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels';
const triplit = useTriplitClient();
const route = useRoute();
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const pendingMessage = ref<Message | null>(null);
const { open: sidebarOpen, openSidebar } = useSidebar();
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string);
const { getAgent } = useAgents();
const { providers } = useModels();
const { autoRename: autoRenameTopic } = useTopic();
const { getAgent, createTopic } = await useAgents();
const { providers } = await useModels();
const agent = getAgent(route.params.id as string);
@@ -31,10 +29,13 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
return;
}
console.log("handleSubmit", message);
if (!message.content) return;
pendingMessage.value = {
id: '',
userId: user.value!.id,
topicId: null,
topicId: '',
content: message.content,
role: 'user',
parts: [],
@@ -46,37 +47,33 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
attachments: [],
deleted: false,
createdAt: new Date(),
updatedAt: new Date(),
}
const topic = await createTopic();
const topic = await createTopic(agent.value!.id);
if (!topic) throw new Error('Failed to create topic');
autoRename(topic.id, message.content);
const { sendMessage, startGeneration } = await useChat(topic.id, false);
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
return sendMessage(message, topic, [], agent.value!, model.provider, model).then(async res => {
sendMessage(message).then(async res => {
if (res.ok === false) {
console.error('Failed to send message:', res.error);
pendingMessage.value = null;
await navigateTo(`/agent/${route.params.id}`);
await triplit.delete('topics', topic.id);
// const chatInput = document.getElementById('chat') as HTMLInputElement;
// if (chatInput) {
// chatInput.value = message;
// chatInput.dispatchEvent(new Event('input'));
// nextTick(() => {
// chatInput.focus();
// });
// }
nextTick(() => {
inputValue.value = message;
});
}
});
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
autoRenameTopic(topic.id);
return startGeneration(model);
};
</script>
@@ -86,10 +83,10 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]">
<div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap">
<h4 class="text-lg truncate">
{{ agent?.name }}
</h4>
</div>
+43 -22
View File
@@ -1,35 +1,56 @@
<script setup lang="ts">
const { getAgent } = useAgents();
const triplit = useTriplitClient();
const { getAgent, patchAgentLocally } = await useAgents();
const route = useRoute();
const { open: sidebarOpen, openSidebar } = useSidebar();
const agent = getAgent(route.params.id as string);
let serverAgent = agent.value;
const name = ref<string | null>(agent.value?.name ?? null);
const systemPrompt = ref<string | null>(agent.value?.systemPrompt ?? null);
if (agent.value === undefined) navigateTo('/');
const handleInput = async (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.value.trimStart().length === 0) {
return;
let debounceTimeout: NodeJS.Timeout | null = null;
const debouncedUpdate = async (updates: Partial<Agent>) => {
if (debounceTimeout !== null) {
clearTimeout(debounceTimeout);
}
await triplit.update('agents', agent.value!.id, { name: target.value });
patchAgentLocally(route.params.id as string, updates);
debounceTimeout = setTimeout(async () => {
debounceTimeout = null;
try {
await $fetch(`/api/agent/${route.params.id}`, {
method: 'PATCH',
body: updates,
});
} catch (error) {
console.error('Failed to update agent:', error);
if (serverAgent) {
patchAgentLocally(route.params.id as string, serverAgent);
name.value = serverAgent.name;
systemPrompt.value = serverAgent.systemPrompt;
}
}
}, 700);
};
const handleNameInput = async (e: Event) => {
const name = (e.target as HTMLInputElement).value;
if (!name.trim()) return;
debouncedUpdate({ name });
};
const changeSystemPrompt = async (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value: string | undefined = target.value;
if (value.trimStart().length === 0) {
value = undefined;
}
await triplit.update('agents', agent.value!.id, {
systemPrompt: target.value,
});
let systemPrompt = (e.target as HTMLTextAreaElement).value as string | null;
if (!systemPrompt!.trim()) systemPrompt = null;
debouncedUpdate({ systemPrompt });
};
</script>
@@ -37,7 +58,7 @@ const changeSystemPrompt = async (e: Event) => {
<div class="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
</div>
@@ -49,15 +70,15 @@ const changeSystemPrompt = async (e: Event) => {
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
<span class="text-16 i-mynaui-check-hexagon"></span>
</div>
<input @input="handleInput" placeholder="Agent Name..."
<input v-model="name" @input="handleNameInput" placeholder="Agent Name..."
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
type="text" :value="agent?.name" />
type="text" />
</div>
<div class="flex flex-col gap-2 w-full h-full mb-14">
<label class="text-sm text-[var(--text-secondary)]">System Message</label>
<textarea placeholder="You are a helpful assistant."
<textarea v-model="systemPrompt" placeholder="You are a helpful assistant."
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
@input="changeSystemPrompt"></textarea>
</div>
</div>
</template>
+52 -228
View File
@@ -1,131 +1,22 @@
<script setup lang="ts">
import type schema from '#triplit/schema';
import type { Entity } from '@triplit/client';
import type { BaseMessage } from '~/composables/useChat';
import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels';
import { buildFocusedMessageTree } from '~~/utils/message';
const rootStart = Date.now();
const triplit = useTriplitClient();
const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
const { getAgent } = useAgents();
const { getAgent } = await useAgents();
const { open: sidebarOpen, openSidebar } = useSidebar();
const { providers, allModels } = useModels();
const { providers, allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts();
const agent = getAgent(route.params.id as string);
const topicQuery = computed(() =>
triplit
.query('topics')
.Where(['id', '=', route.params.topicId])
.Limit(1)
);
const messagesQuery = computed(() =>
triplit
.query('messages')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
);
const attachmentsQuery = computed(() =>
triplit
.query('attachments')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
);
const partsQuery = computed(() =>
triplit
.query('message_parts')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
.Include('toolCall')
);
const generationsQuery = computed(() =>
triplit
.query('generations')
.Where(['topicId', '=', route.params.topicId])
);
const [
{ results: rawTopic, unsubscribe: unsubscribeTopic },
{ results: rawMessages, unsubscribe: unsubscribeMessages },
{ results: rawAttachments, unsubscribe: unsubscribeAttachments },
{ results: rawParts, unsubscribe: unsubscribeParts },
{ results: rawGenerations, unsubscribe: unsubscribeGenerations }
] = await Promise.all([
useQuery('topic', triplit, topicQuery),
useQuery('messages', triplit, messagesQuery),
useQuery('attachments', triplit, attachmentsQuery),
useQuery('parts', triplit, partsQuery),
useQuery('generations', triplit, generationsQuery),
]);
const topic = computed(() => {
if (!rawMessages.value || !rawTopic.value?.[0]) return null;
const messagesMap = new Map();
const partsByMessage = new Map<string, Entity<typeof schema, 'message_parts'>[]>();
const attachmentsByMessage = new Map<string, Entity<typeof schema, 'attachments'>[]>();
// Group parts by message ID once
if (rawParts.value) {
for (const part of rawParts.value) {
if (!partsByMessage.has(part.messageId)) {
partsByMessage.set(part.messageId, []);
}
if (part.content !== '' || part.toolCall !== null) {
partsByMessage.get(part.messageId)!.push(part);
}
}
}
if (rawAttachments.value) {
for (const attachment of rawAttachments.value) {
if (!attachmentsByMessage.has(attachment.messageId)) {
attachmentsByMessage.set(attachment.messageId, []);
}
attachmentsByMessage.get(attachment.messageId)!.push(attachment);
}
}
const generationsMap = new Map(
rawGenerations.value?.map(g => [g.id, g]) ?? []
);
// Single pass to build messages
for (const msg of rawMessages.value) {
messagesMap.set(msg.id, {
...msg,
attachments: attachmentsByMessage.get(msg.id) ?? [],
parts: partsByMessage.get(msg.id) ?? [],
children: [],
generation: generationsMap.get(msg.generationId!) ?? null
});
}
// Build tree
const rootMessages: Message[] = [];
for (const msg of messagesMap.values()) {
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
messagesMap.get(msg.parentMessageId)!.children.push(msg);
} else {
rootMessages.push(msg);
}
}
return {
...rawTopic.value[0],
messages: rootMessages as Message[],
generations: rawGenerations.value || []
};
});
const topicId = computed(() => route.params.topicId as string);
const { topic, sendMessage, startGeneration, regenerateMessage, patchMessageLocally } = await useChat(topicId);
watch(() => topic.value?.name, (newTopicName) => {
if (newTopicName !== undefined) {
@@ -139,7 +30,14 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
return;
}
const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
inputValue.value = { content: '', fileIds: [] };
const res = await sendMessage(message, async () => {
await nextTick();
setTimeout(() => {
scrollToBottom('instant')
});
});
if (!res.ok) {
console.error('Failed to send message:', res.error);
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
@@ -153,33 +51,16 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
return;
}
scrollToBottom('instant');
startGeneration(model);
};
const focusedMessageTree = computed(() => {
const tree: Readonly<MessageEntity>[] = [];
for (const message of topic.value?.messages || []) {
if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
if (message.focusedIndex === 0) {
tree.push(message);
continue;
}
tree.push(message.children[message.focusedIndex - 1]!);
} else {
tree.push(message);
}
}
return tree;
})
const handleRegenerate = async (message: Message) => {
if (!agent.value!.defaultModelId) {
console.error('No model selected');
return;
}
let messageId;
let messageId: string;
if (
(message.focusedIndex !== undefined && message.focusedIndex !== null)
&& message.focusedIndex > 0
@@ -191,90 +72,41 @@ const handleRegenerate = async (message: Message) => {
}
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
if (!model) {
console.error('Model not found');
if (!model || !model.provider) {
console.error('Model not found or provider not found');
return;
}
const res = await regenerateMessage(messageId, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
const res = await regenerateMessage(messageId, buildFocusedMessageTree(topic.value!.messages), model);
if (!res.ok) {
console.error('Failed to regenerate message:', ChatErrorType[res.error]);
return;
}
}
const deeplyDeleteMessage = async (message: MessageEntity) => {
triplit.delete('messages', message.id);
if (message.generationId !== null && message.generationId !== undefined) {
triplit.delete('generations', message.generationId);
}
for (const part of message.parts || []) {
triplit.delete('message_parts', part.id);
}
if (topic.value?.messages.filter(m => m.id !== message.id).length === 0) {
triplit.delete('topics', topic.value!.id);
return navigateTo(`/agent/${route.params.id}/`);
}
}
const handleDelete = async (rootMessage: Message) => {
if (rootMessage.role === 'user') {
deeplyDeleteMessage(rootMessage);
return;
}
if (rootMessage.deleted === true) {
const message = rootMessage.children[rootMessage.focusedIndex!];
if (!message) {
console.error('Message not found');
return;
}
deeplyDeleteMessage(message);
if (rootMessage.children.filter(child => child!.id !== message.id).length === 0) {
deeplyDeleteMessage(rootMessage);
}
return;
}
// - If the message has children, check if they are all soft deleted
// - If they are all soft deleted, delete the message
// - If they are not all soft deleted, mark only this message as deleted
if (
(rootMessage.focusedIndex !== undefined && rootMessage.focusedIndex !== null)
&& rootMessage.focusedIndex > 0
&& rootMessage.children.length > 0
) {
// we are a child message
const message = rootMessage.children[rootMessage.focusedIndex - 1]!;
if (!message) {
console.error('Message not found');
return;
}
deeplyDeleteMessage(message);
return;
}
// we have no children
if (rootMessage.children.length === 0) {
deeplyDeleteMessage(rootMessage);
return;
}
// we are a root message and we have at least one living child, soft delete
await triplit.update('messages', rootMessage.id, {
deleted: true
await $fetch(`/api/messages/${rootMessage.id}`, {
method: 'DELETE',
});
}
const flatMessages = computed(() => {
const messages: Message[] = [];
for (const message of topic.value?.messages ?? []) {
messages.push(message);
if (message.children.length > 0) {
messages.push(...message.children as Message[]);
}
}
return messages;
});
const activeGeneration = computed(() => {
if (topic.value === null) return null;
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
const generations = flatMessages.value.flatMap(message => message.generation);
return generations?.find((generation) => generation?.status === 'pending') ?? null;
});
const { scrollToBottom } = useAutoScroll(chatPaneWrapper);
@@ -318,9 +150,8 @@ addShortcut(['alt', '['], async (event) => {
const lastMessage = topic.value?.messages?.at(-1);
if (lastMessage && lastMessage.children.length > 0) {
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1)
});
const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
@@ -329,10 +160,10 @@ addShortcut(['alt', ']'], async (event) => {
event.stopPropagation();
const lastMessage = topic.value?.messages?.at(-1);
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
if (lastMessage && lastMessage.children.length > 0) {
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1)
});
const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
@@ -342,21 +173,20 @@ addShortcut(['ctrl', 'alt', 'arrowleft'], async (event) => {
event.preventDefault();
event.stopPropagation();
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1)
});
const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => {
const lastMessage = topic.value?.messages?.at(-1);
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
if (lastMessage && lastMessage.children.length > 0) {
event.preventDefault();
event.stopPropagation();
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1)
});
const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
@@ -365,20 +195,13 @@ onMounted(() => {
});
const handleCancel = async () => {
await $fetch(`/api/chat/cancel/${activeGeneration.value?.id}`, {
// TODO: cancel generation
await $fetch(`/api/topic/${topicId.value}/chat/cancel/${activeGeneration.value?.id}`, {
method: 'POST',
});
};
console.log("full page render took", Date.now() - rootStart);
onUnmounted(() => {
unsubscribeTopic?.();
unsubscribeMessages?.();
unsubscribeAttachments?.();
unsubscribeParts?.();
unsubscribeGenerations?.();
});
</script>
<template>
@@ -386,10 +209,10 @@ onUnmounted(() => {
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]">
<div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap">
<h4 class="text-lg truncate">
{{ topic?.name }}
</h4>
</div>
@@ -403,7 +226,8 @@ onUnmounted(() => {
<Suspense>
<template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
<Message v-for="message in topic.messages" :key="message.id" :message="message"
v-memo="[message.id, message.parts?.length, message.children, message.focusedIndex, message.content]"
@edit="(value) => patchMessageLocally(message.id, { content: value })"
@patch="(updates) => patchMessageLocally(message.id, updates)"
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" />
</template>
</Suspense>
+1 -4
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { deriveKey } from '~/utils/crypto';
import { initSettings } from '~/utils/settings';
const { client: authClient, signIn, session } = useAuth();
@@ -101,8 +100,6 @@ const submit = async () => {
const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
initSettings(res.data.user.id);
return navigateTo(to ?? '/');
};
</script>
@@ -152,7 +149,7 @@ const submit = async () => {
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
New to Veridian?
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
class="text-[var(--color-accent)] font-medium hover:underline">
class="text-[var(--color-accent)] font-medium @hover:underline">
Create an account
</NuxtLink>
</p>
+1 -4
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { deriveKey } from '~/utils/crypto';
import { initSettings } from '~/utils/settings';
const { client: authClient, signUp, session } = useAuth();
@@ -134,8 +133,6 @@ const submit = async () => {
const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
initSettings(res.data.user.id);
return navigateTo(to ?? '/');
};
</script>
@@ -203,7 +200,7 @@ const submit = async () => {
<p class="text-sm text-center mt-3 text-[var(--text-secondary)]">
Already have an account?
<NuxtLink :to="to ? `/auth/login?to=${to}` : '/auth/login'"
class="text-[var(--color-accent)] font-medium hover:underline">
class="text-[var(--color-accent)] font-medium @hover:underline">
Log in here
</NuxtLink>
</p>
+28 -44
View File
@@ -1,13 +1,9 @@
<script setup lang="ts">
import { assert } from '~~/utils/assert';
import type { BaseMessage } from '~/composables/useChat';
import type { Agent } from '~/composables/useAgents';
const triplit = useTriplitClient();
const { open: sidebarOpen, openSidebar } = useSidebar();
const { agents, createAgent } = useAgents();
const { providers, getFirstAvailableModel, allModels } = useModels();
const { agents, createAgent, createTopic } = await useAgents();
const { providers } = await useModels();
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
@@ -80,60 +76,48 @@ const agent = computed(() => {
return agents.value?.[0] ?? null;
});
const { autoRename: autoRenameTopic } = useTopic();
const handleChatSubmit = async (message: BaseMessage, model: ModelWithProvider | null) => {
console.log('Message submitted:', message, agents);
let agent: Agent | null = agents.value?.[0] ?? null;
if (!agent) {
const triplit = useTriplitClient();
assert('flush' in triplit);
agent = await createAgent();
await triplit.flush();
}
if (!agent) throw new Error('Failed to find agent');
if (!model) {
if (agent.defaultModelId) {
model = allModels.value.find(m => m.id === agent.defaultModelId) ?? null;
} else {
model = getFirstAvailableModel();
}
}
if (!model) {
console.error('No model selected');
return;
}
const { createTopic, autoRename, sendMessage } = useChat(agent.id);
if (!message.content) return;
const topic = await createTopic();
if (!topic) throw new Error('Failed to create topic');
let targetAgent = agents.value?.[0] ?? null;
if (!targetAgent) {
targetAgent = await createAgent(false);
if (!targetAgent) {
console.error('Failed to create agent');
return;
}
}
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`);
const topic = await createTopic(targetAgent.id);
if (!topic) {
console.error('Failed to create topic');
return;
}
autoRename(topic.id, message.content);
const { sendMessage, startGeneration } = await useChat(topic.id, false);
return sendMessage(message, topic, [], agent, model.provider, model).then(async res => {
await sendMessage(message).then(async res => {
if (res.ok === false) {
console.error('Failed to send message:', res.error);
await navigateTo(`/agent/${agent.id}`);
await triplit.delete('topics', topic.id);
// const chatInput = document.getElementById('chat') as HTMLInputElement;
// if (chatInput) {
// chatInput.value = message;
// chatInput.dispatchEvent(new Event('input'));
// nextTick(() => {
// chatInput.focus();
// });
// }
await navigateTo('/');
nextTick(() => {
inputValue.value = message;
});
}
});
await navigateTo(`/agent/${targetAgent.id}/topic/${topic.id}`);
autoRenameTopic(topic.id);
return startGeneration(model);
};
onMounted(() => {
@@ -166,7 +150,7 @@ onUnmounted(() => {
<div class="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
</div>
+1 -9
View File
@@ -1,11 +1,10 @@
import { assert } from "~~/utils/assert";
export default defineNuxtPlugin({
name: 'better-auth-triplit',
name: 'better-auth',
enforce: 'pre',
async setup(nuxtApp) {
if (import.meta.client) {
const triplit = useTriplitClient();
const { session, fetchSession } = useAuth();
nuxtApp.hook('app:mounted', async () => {
@@ -16,13 +15,6 @@ export default defineNuxtPlugin({
return;
}
}
if (!session.value) {
return;
}
assert('startSession' in triplit)
await triplit.startSession(session.value.token);
});
}
},
-10
View File
@@ -4,8 +4,6 @@ export default defineNuxtPlugin({
name: 'better-auth-fetch-plugin',
enforce: 'pre',
async setup(nuxtApp) {
const triplit = useTriplitClient();
// Flag if request is cached
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache);
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
@@ -18,14 +16,6 @@ export default defineNuxtPlugin({
return;
}
}
if (!session.value) return;
assert('updateOptions' in triplit);
triplit.updateOptions({
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: session.value.token,
});
}
},
});
-13
View File
@@ -1,13 +0,0 @@
export default defineNuxtPlugin({
name: 'global-data',
enforce: 'pre',
async setup() {
await Promise.all([
useAgents().init(),
useModels().init(),
useUserSettings().init()
]);
console.log('Global data ready');
}
});
+18 -4
View File
@@ -1,16 +1,30 @@
import { schema } from '#triplit/schema';
import type { Entity } from '@triplit/client';
import * as schema from '~~/drizzle/schema';
export const Providers = ['openrouter', 'ollama', 'cerebras', 'google', 'longcat', 'cohere'] as const;
export const Providers = [
'openrouter',
'ollama',
'vllm',
'cerebras',
'google',
'longcat',
'cohere',
'inception',
'mistral',
'closedrouter',
] as const;
export const SupportedModalities = ['text', 'image', 'audio', 'video', 'pdf'] as const;
export const providerBaseUrls = {
openrouter: 'https://openrouter.ai/api/v1',
vllm: '',
ollama: '',
cerebras: 'https://api.cerebras.ai/v1',
google: 'https://generativelanguage.googleapis.com/v1beta',
longcat: 'https://api.longcat.chat/openai/v1',
cohere: 'https://api.cohere.ai/v2',
inception: 'https://api.inceptionlabs.ai/v1',
mistral: 'https://api.mistral.ai/v1',
closedrouter: 'https://router.queef.in/v1',
};
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> };
export type Model = typeof schema.models.$inferSelect;
+7
View File
@@ -50,6 +50,8 @@ import {
LogoAionLabs,
LogoMicrosoft,
LogoInflection,
LogoVllm,
LogoClosedRouter,
} from '#components';
import { markRaw } from 'vue';
@@ -186,6 +188,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
/magistral/,
/devstral/,
/voxtral/,
/leanstral/,
],
},
{
@@ -325,6 +328,10 @@ export const providerIcons: Record<string, any | null> = {
openrouter: markRaw(LogoOpenrouter),
cerebras: markRaw(LogoCerebras),
longcat: markRaw(LogoLongCat),
inception: markRaw(LogoInception),
mistral: markRaw(LogoMistral),
vllm: markRaw(LogoVllm),
closedrouter: markRaw(LogoClosedRouter),
};
export function getModelConfig(modelId: string) {
-19
View File
@@ -1,19 +0,0 @@
export const initSettings = async (userId: string) => {
const triplit = useTriplitClient();
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
if (!settings) {
console.log('no settings');
await triplit.insert('settings', {
userId: userId,
systemAssistants: {
rename: {
enabled: false,
prompt: null,
modelId: null,
}
}
});
}
}
+2
View File
@@ -1,4 +1,6 @@
export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => {
if (typeof a.releasedAt === 'string') a.releasedAt = new Date(a.releasedAt);
if (typeof b.releasedAt === 'string') b.releasedAt = new Date(b.releasedAt);
if (!a.releasedAt && !b.releasedAt) return 0;
if (!a.releasedAt) return 1;
if (!b.releasedAt) return -1;