Performance enhancements galore! New themining system

This is once again a huge commit, but its mostly performance
improvements along with some bug fixes and refactoring. It also includes
changes to the theming systems. I'm still not 100% happy with the
theming system, but its better than before.

Model fetching has been dramatically improved! Nearly all the important
computation and pre-processing has been moved to the server. This has
also somehow fixed the way model details are loaded, which was causing
many models to be missing their details despite models.dev having them.

The markdown renderer has once again been changed, but I'm mostly
certain that this is the last time major changes will be made to it. The
renderer is not spamming components, bloating memory usage, and its not
using a bug prone custom written chunking system.

There's also a lot more that I haven't mentioned and honestly forgot. I
need to get better commit hygiene tbh.
This commit is contained in:
Zoe
2026-02-19 23:44:23 -06:00
parent 32a4f7f95d
commit 59bb7fbc12
85 changed files with 3523 additions and 2039 deletions
+3 -3
View File
@@ -4,10 +4,10 @@
I'm not entire sure why, I think its a race condition, but I'm not sure.
I _do not_ think that triplit-nuxt is the culprit, but I'm not sure.
2. [X] The logic to handle hovering over the sidebar is half-baked at best. On the
2. [x] The logic to handle hovering over the sidebar is half-baked at best. On the
agents route, the back arrow sometimes stays full sized
3. [X] If you open the theme switcher on the sidenav then close it and re-open
3. [x] If you open the theme switcher on the sidenav then close it and re-open
it _without_ moving your mouse off of the sidenav, the agent button/dropdown
trigger will slowly crawl to the right (likely related to #2).
@@ -28,4 +28,4 @@
If input was typed into the chat input **before hydation** the message send button
will be on the left. **THIS WAS BECAUSE OF GRAMMARLY. I HATE YOU GRAMMARLY.**
10. [ ] Multiple markdown blocks might be edited with the same content at the same time
10. [ ] Multiple markdown blocks might be edited with the same content at the same time
+20 -2
View File
@@ -1,5 +1,23 @@
# Todo
- [X] Standardize on one icon set rather than 4 (lmao)
- [ ] Make dropdowns a singleton component
- [x] Standardize on one icon set rather than 4 (lmao)
- [-] Make dropdowns a singleton component (work in progress)
- [ ] Make the sidebar better :kekdoggo:. It's annoying to manage across routes
- [ ] Make topics manually renameable
- [ ] Improve latex rendering, make single line equations work _well_, and likely make it configurable
- [x] Implement appearence settings
- [x] Accent colors
- [x] Neutral colors
- [x] Hinting
- [ ] Font size/UI scale
- [ ] Fix custom longcat tool call
- [ ] add the ability to make a custom provider
- [ ] add browser testing (maybe https://vitest.dev/guide/browser/)
- [ ] Add a dropdown to the output token count on agent messages that shows you all the token info
- [ ] Add the ability to configure UI scale and font size
- [ ] Write good docs
- [ ] Custom theme colors
- [ ] Enable and disable all models button
- [ ] Make tools pluggable
- [ ] Make a tool connector that the server can use to connect to tools on a
remote system (would be helpful for agentic development tasks)
+17 -15
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
// import 'katex/dist/katex.min.css'
import '~/assets/css/reset.css';
import '~/assets/css/base.css';
@@ -9,7 +11,14 @@ if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
useHead({
htmlAttrs: {
style: `--color-accent: var(--accent-${accent.value}, var(--accent-violet)); --color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover)); --neutral-dark: var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark)); --neutral-light: var(--neutral-${neutral.value}-light, var(--neutral-zinc-light)); --accent-hinting: ${hinting.value}%; --base-hinting: calc(100% - var(--accent-hinting));`,
style: `
--neutral-100: var(--palette-${neutral.value}-100);
--neutral-200: var(--palette-${neutral.value}-200);
--neutral-300: var(--palette-${neutral.value}-300);
--color-accent: var(--accent-${accent.value}, var(--accent-violet));
--color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover));
--accent-hinting: ${hinting.value}%;
--base-hinting: calc(100% - var(--accent-hinting));`,
},
});
@@ -26,23 +35,16 @@ if (import.meta.client) {
'--color-accent-hover',
`var(--accent-${accent.value}-hover, var(--accent-violet-hover))`,
);
document.documentElement.style.setProperty(
'--neutral-dark',
`var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`,
);
document.documentElement.style.setProperty(
'--neutral-light',
`var(--neutral-${neutral.value}-light, var(--neutral-zinc-light))`,
);
// document.documentElement.style.setProperty(
// '--color-neutral',
// `var(--neutral-${neutral.value}, var(--neutral-zinc))`,
// );
document.documentElement.style.setProperty('--neutral-100', `var(--palette-${neutral.value}-100)`);
document.documentElement.style.setProperty('--neutral-200', `var(--palette-${neutral.value}-200)`);
document.documentElement.style.setProperty('--neutral-300', `var(--palette-${neutral.value}-300)`);
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`);
});
}
useHead({
bodyAttrs: {
class: 'font-sans'
}
})
</script>
<template>
+344 -21
View File
@@ -1,9 +1,7 @@
:root {
--font-sans: system-ui, sans-serif;
--sidebar-width: 400px;
--spacing: 0.25rem;
--color-accent-text: #000;
--color-accent-text: #232625;
--reasoning-accent: #a62bcb;
@@ -53,18 +51,68 @@
--accent-magenta: #ec4899;
--accent-magenta-hover: #db2777;
/* Neutral Base Colors - different gray variations */
/* zinc - deep gray-blue */
--neutral-zinc-dark: #171619;
--neutral-zinc-light: #fbfafd;
/* charcoal - deep black-gray */
--neutral-charcoal-dark: #0a0a0a;
--neutral-charcoal-light: #fefdff;
--bg-base: color-mix(in srgb, var(--neutral-100), var(--color-accent) var(--accent-hinting));
--bg-surface: color-mix(in srgb, var(--neutral-200), var(--color-accent) var(--accent-hinting));
--bg-container: color-mix(in srgb, var(--neutral-300), var(--color-accent) var(--accent-hinting));
}
:root.dark {
--palette-zinc-100: #09080A;
--palette-zinc-200: #1a191b;
--palette-zinc-300: #2e2d2f;
--palette-slate-100: #0f171c;
--palette-slate-200: #1e252b;
--palette-slate-300: #333b44;
--palette-obsidian-100: #050504;
--palette-obsidian-200: #0f0f0e;
--palette-obsidian-300: #1e1f1f;
--text-primary: color-mix(in srgb, #fafafa, var(--color-accent) var(--accent-hinting));
/* Selected topics */
--text-secondary: color-mix(in srgb, #a1a1aa, var(--color-accent) var(--accent-hinting));
/* Selected topics */
--text-tertiary: color-mix(in srgb, #71717a, var(--color-accent) var(--accent-hinting));
/* Tokens/s, Info */
--text-dim: color-mix(in srgb, #52525b, var(--color-accent) var(--accent-hinting));
/* Unselected/Disabled */
--color-hover: color-mix(in srgb, rgba(255, 255, 255, 0.08), var(--color-accent) var(--accent-hinting));
--color-active: color-mix(in srgb, rgba(255, 255, 255, 0.12), var(--color-accent) var(--accent-hinting));
--color-border: color-mix(in srgb, rgba(255, 255, 255, 0.1), var(--color-accent) var(--accent-hinting));
--color-border-active: color-mix(in srgb, rgba(255, 255, 255, 0.16), var(--color-accent) var(--accent-hinting));
}
:root.light {
/* --palette-zinc-100: #f4f4f5; --palette-zinc-200: #e4e4e7; --palette-zinc-300: #d4d4d8;
--palette-slate-100: #f1f5f9; --palette-slate-200: #e2e8f0; --palette-slate-300: #cbd5e1;
--palette-obsidian-100: #fafafa; --palette-obsidian-200: #f5f5f5; --palette-obsidian-300: #e5e5e5; */
--palette-zinc-100: #d4d4d8;
--palette-zinc-200: #e4e4e7;
--palette-zinc-300: #f4f4f5;
--palette-slate-100: #cbd5e1;
--palette-slate-200: #e2e8f0;
--palette-slate-300: #f1f5f9;
--palette-obsidian-100: #e5e5e5;
--palette-obsidian-200: #ededed;
--palette-obsidian-300: #fcfcfc;
--text-primary: color-mix(in srgb, #09090b, var(--color-accent) var(--accent-hinting));
--text-secondary: color-mix(in srgb, #52525b, var(--color-accent) var(--accent-hinting));
--text-tertiary: color-mix(in srgb, #71717a, var(--color-accent) var(--accent-hinting));
--text-dim: color-mix(in srgb, #a1a1aa, var(--color-accent) var(--accent-hinting));
--color-hover: color-mix(in srgb, rgba(0, 0, 0, 0.08), var(--color-accent) var(--accent-hinting));
--color-active: color-mix(in srgb, rgba(0, 0, 0, 0.12), var(--color-accent) var(--accent-hinting));
--color-border: color-mix(in srgb, rgba(0, 0, 0, 0.08), var(--color-accent) var(--accent-hinting));
--color-border-active: color-mix(in srgb, rgba(0, 0, 0, 0.14), var(--color-accent) var(--accent-hinting));
}
/* :root.dark {
--neutral-zinc: color-mix(in srgb, var(--base-hinting) #171619, var(--accent-hinting) var(--color-accent));
--neutral-charcoal: color-mix(in srgb, var(--base-hinting) #0a0a0a, var(--accent-hinting) var(--color-accent));
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb,
var(--base-hinting) rgba(255, 255, 255, 0.05),
@@ -79,13 +127,13 @@
--color-muted: color-mix(in srgb, var(--base-hinting) #a1a1a0, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #d1d1d0, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb,
var(--base-hinting) var(--neutral-dark),
var(--accent-hinting) var(--color-accent));
--color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
}
:root.light {
--neutral-zinc: color-mix(in srgb, var(--base-hinting) #fbfafd, var(--accent-hinting) var(--color-accent));
--neutral-charcoal: color-mix(in srgb, var(--base-hinting) #fefdff, var(--accent-hinting) var(--color-accent));
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb,
var(--base-hinting) rgba(0, 0, 0, 0.06),
@@ -100,18 +148,23 @@
--color-muted: color-mix(in srgb, var(--base-hinting) #6b7270, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #9ba2a0, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb,
var(--base-hinting) var(--neutral-light),
var(--accent-hinting) var(--color-accent));
--color-reasoning: color-mix(in srgb, var(--base-hinting) #99979c, var(--accent-hinting) var(--color-accent));
}
} */
html,
body {
font-family: "Geist", ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans",
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
padding: 0;
margin: 0;
background-color: var(--color-base);
color: var(--color-text);
background-color: var(--bg-base);
color: var(--text-primary);
}
.font-mono {
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
html.dark {
@@ -154,4 +207,274 @@ button.accent:hover {
.capitalize {
text-transform: capitalize;
}
.has-obtrusive-scrollbars .chat-scroll-container {
scrollbar-gutter: stable;
}
.has-obtrusive-scrollbars .chatPane {
margin-right: calc(-1 * var(--thin-scrollbar-width));
width: calc(100% + var(--thin-scrollbar-width));
}
.animate-blink {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
.animate-rotate {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.vl-toggle-switch[aria-disabled="true"] div {
opacity: 0.5;
cursor: not-allowed;
}
.vl-toggle-switch[data-state="checked"] {
background: var(--color-accent);
}
.vl-toggle-switch[data-state="checked"] div {
transform-origin: center right;
transform: translateX(calc(2.5em - 1em - 0.5rem));
}
.vl-toggle-switch:active div {
width: 1.3em;
}
.vl-toggle-switch[data-state="checked"]:active div {
transform: translateX(calc(2.5em - 1.3em - 0.5rem));
}
.reasoning-contaizner.middle {
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.top {
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.bottom {
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
}
/* Markdown renderer */
article>* {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
article>*:first-child {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
article>*:last-child {
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
article>*:only-child {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
article math {
font-size: 1.2em;
}
article mtd {
padding: 0.25rem 0.5rem;
}
article hr {
border: 1px solid var(--text-dim);
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
article li {
min-height: 24px;
}
article ul {
list-style: none;
margin-left: 1.25rem;
margin-top: 1.25rem;
}
article ul>li {
position: relative;
padding-bottom: 0.75rem;
padding-left: 1.5rem;
}
article ul>li::before {
content: "";
position: absolute;
left: 0;
top: 0.5rem;
width: 7px;
height: 7px;
background-color: var(--text-tertiary);
border-radius: 50%;
}
article ul>li::after {
content: "";
position: absolute;
left: 3px;
top: 23px;
bottom: 0;
width: 1px;
background-color: var(--text-dim);
}
article ul>li:last-child::after {
display: none;
}
article ol {
list-style: none;
margin-left: 1.25rem;
margin-top: 1.25rem;
counter-reset: ordered-list-counter var(--start-value, 0);
}
article ol[start] {
--start-value: calc(attr(start type(<number>)) - 1);
}
article ol>li {
position: relative;
padding-bottom: 0.75rem;
padding-left: 1.5rem;
counter-increment: ordered-list-counter;
}
article ol>li::before {
content: counter(ordered-list-counter) ".";
position: absolute;
left: 0;
top: 0;
color: var(--color-muted);
font-weight: 500;
width: 1.25rem;
}
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
article ol:only-child,
article ul:only-child {
margin-top: 0 !important;
}
article code:not(pre code) {
background-color: var(--color-hover);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
article blockquote {
color: var(--text-secondary);
border-left: 4px solid var(--text-tertiary);
padding-left: 0.5rem;
}
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
article table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
text-align: left;
background-color: var(--bg-base);
color: var(--color-text);
}
article table thead tr {
background-color: var(--color-hover);
}
article table th {
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.05em;
}
article table td {
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
}
article table tbody tr {
background-color: var(--color-hover);
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
}
article table tbody tr:nth-of-type(even) {
background-color: var(--color-hover);
}
/* Hover effect */
article table tbody tr:hover {
background-color: var(--color-active);
}
article h1,
article h2,
article h3,
article h4,
article h5,
article h6 {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
article .checkbox {
width: min-content;
}
/* end markdown renderer */
.reasoning-contaizner.middle {
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.top {
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.bottom {
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
}
+18 -28
View File
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { onMounted, ref, watch, computed } from 'vue';
import { onMounted, ref, watch } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Entity } from '@triplit/client';
import { schema } from '#triplit/schema';
const { allModels } = await useModels();
const inputHeight: Ref<string> = ref('auto');
const inputRef = ref<HTMLTextAreaElement | null>(null);
let tempInput = '';
const inputValue = ref('');
const inputValue = defineModel<string>({ required: false, default: '' });
const triplit = useTriplitClient();
const emit = defineEmits<{
@@ -22,10 +23,8 @@ const props = defineProps<{
providers?: ProviderWithModels[];
}>();
// Model selection state
const selectedModel = ref<ModelWithProvider | null>(null);
// Initialize model selection based on agent's defaultModelId or first available
const initializeModel = () => {
if (selectedModel.value) return;
if (!props.providers || !props.agent) return;
@@ -42,12 +41,10 @@ const initializeModel = () => {
// Fall back to first available model
if (allModels.value.length > 0) {
selectedModel.value = allModels.value[0]!;
// Update agent's default model
updateAgentDefaultModel(selectedModel.value.id);
}
};
// Update agent's default model in Triplit
const updateAgentDefaultModel = async (modelId: string) => {
if (!props.agent) return;
try {
@@ -103,8 +100,6 @@ const handleKeyDown = async (event: KeyboardEvent) => {
inputValue.value =
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
await nextTick();
handleInput();
return;
}
event.preventDefault();
@@ -112,11 +107,12 @@ const handleKeyDown = async (event: KeyboardEvent) => {
}
};
const handleInput = () => {
watch(inputValue, async () => {
const textarea = inputRef.value;
if (!textarea) return;
textarea.style.height = 'auto';
inputHeight.value = 'auto';
await nextTick();
const lineHeight = 24;
const maxLines = 10;
@@ -125,11 +121,11 @@ const handleInput = () => {
const newHeight = textarea.scrollHeight;
if (newHeight > maxHeight) {
textarea.style.height = `${maxHeight}px`;
inputHeight.value = `${maxHeight}px`;
} else {
textarea.style.height = `${newHeight}px`;
inputHeight.value = `${newHeight}px`;
}
};
}, { immediate: true });
let hasCommandKey = false;
if (import.meta.server) {
@@ -145,40 +141,34 @@ onBeforeMount(() => {
onMounted(() => {
inputValue.value = tempInput;
nextTick(() => {
handleInput();
});
});
</script>
<template>
<div :class="['w-full flex max-h-full', $attrs.class]">
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--color-input)]
border-[var(--color-highlight)] focus-within:border-[var(--color-highlight-high)]">
<!-- Text Input -->
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
<div class="flex-1 min-w-0 max-h-full">
<!-- Grammarly literally breaks everything, go fuck yourself -->
<!-- It is absolutely paramount that the closing tag for the textare has ZERO whitespace between the end of the textarea opening tag, otherwise there will be hydration errors -->
<textarea data-gramm="false" id="chat" v-model="inputValue" ref="inputRef"
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
@keydown="handleKeyDown" @input="handleInput"
class="[scrollbar-width:none] w-full bg-transparent text-[var(--color-text)] resize-none outline-none text-[15px] leading-6 min-h-0 overflow-y-auto">
</textarea>
@keydown="handleKeyDown" :style="{ height: inputHeight }"
class="[scrollbar-width:none] w-full bg-transparent resize-none text-[0.95em] placeholder:text-[var(--text-tertiary)]"></textarea>
</div>
<!-- Toolbar -->
<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-1">
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers">
</ModelSelector>
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers" />
</div>
<!-- Send/Stop Button -->
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !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.trim() && !loading
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
: 'text-[var(--color-highlight-high)]',
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]',
: 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]',
]">
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
<Icon v-else name="mynaui:send-solid" class="text-5" />
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{
isOpen: boolean;
}>();
</script>
<template>
<div class="grid transition-all duration-250 ease-in-out transform-origin-top-center" :class="[
isOpen ? 'grid-rows-[1fr] opacity-100 scale-100' : 'grid-rows-[0fr] opacity-0 scale-95',
]">
<div class="overflow-hidden">
<slot />
</div>
</div>
</template>
+20 -86
View File
@@ -1,97 +1,31 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown';
<script setup>
import { useDropdown } from '~/composables/useDropdown';
const { dropdownState, closeDropdown } = useDropdown();
const menuRef = ref(null);
interface Props {
items?: DropdownItem[];
placement?: 'right' | 'left' | 'center';
verticality?: 'asscending' | 'descending';
width?: string;
}
const currentItems = computed(() => dropdownState.itemsFactory?.());
const props = withDefaults(defineProps<Props>(), {
placement: 'right',
verticality: 'descending',
width: 'auto',
});
const emit = defineEmits<(e: 'select', item: DropdownItem) => void>();
const triggerRef = ref<HTMLElement | null>(null);
const isOpen = defineModel<boolean>({ required: false });
const toggle = () => {
isOpen.value = !isOpen.value;
};
const select = (item: DropdownItem) => {
if (item.disabled || item.divider) return;
emit('select', item);
isOpen.value = false;
};
const placementClasses = computed(() => {
let classes = '';
switch (props.placement) {
case 'right':
classes += 'right-0 ';
break;
case 'left':
classes += 'left-0 ';
break;
case 'center':
classes += 'left-1/2 -translate-x-1/2 ';
break;
default:
classes += 'left-0 ';
break;
}
switch (props.verticality) {
case 'asscending':
classes += 'bottom-full mb-1.5';
break;
case 'descending':
classes += 'top-full mt-1.5';
break;
}
return classes;
});
useClickOutside(triggerRef, () => {
isOpen.value = false;
});
useClickOutside(menuRef, closeDropdown);
</script>
<template>
<div ref="triggerRef" :class="$attrs.class">
<slot name="trigger" :toggle="toggle" :is-open="isOpen"></slot>
<Teleport to="body">
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen" :class="[
'absolute z-50 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-1.5 flex flex-col gap-1',
placementClasses
]" :style="{ width: width !== 'auto' ? width : undefined }">
<template v-for="(item, index) in items" :key="index">
<div v-if="item.divider" class="h-px bg-[var(--color-highlight)] my-1" />
<button @click="select(item); item.onClick && item.onClick()" :disabled="item.disabled"
class="bg-transparent w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left"
:class="[
item.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer'
]">
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
<span class="text-sm whitespace-nowrap">{{ item.label }}</span>
<slot name="item-after" :item="item"></slot>
</button>
</template>
<slot name="content" :toggle="toggle"></slot>
<div v-if="dropdownState.open" ref="menuRef"
:style="{ top: `${dropdownState.y}px`, left: `${dropdownState.x}px`, width: dropdownState.options?.width || 'auto', minWidth: dropdownState.options?.minWidth || 'auto', maxWidth: dropdownState.options?.maxWidth || 'auto', transform: `translate(${dropdownState.options?.placement === 'right' ? '-100%' : dropdownState.options?.placement === 'center' ? '-50%' : '0'}, ${dropdownState.options?.verticality === 'ascending' ? 'calc(-100% - 0.25rem)' : '0.25rem'})` }"
class="flex flex-col gap-1 fixed z-[100] bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-lg p-1.5 shadow-xl">
<!-- Render your items based on activeMenu.content -->
<button v-for="item in currentItems" @click="item.onClick(); closeDropdown()" :disabled="item.disabled"
class="disabled:opacity-50 disabled:cursor-not-allowed items-center gap-1 text-left px-3 py-1.5 text-sm hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="{ 'text-red-600 hover:bg-red-600/20': item.danger, 'text-primary bg-[var(--color-hover)]': item.active }">
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
<span class="text-sm whitespace-nowrap text-ellipsis max-w-full overflow-hidden">{{ item.label
}}</span>
</button>
</div>
</Transition>
</div>
</template>
</Teleport>
</template>
File diff suppressed because one or more lines are too long
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'Cerebras';
const BACKGROUND_COLOR = "#F15A29";
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;"
: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' : ''" />
<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>
</div>
</template>
+2 -1
View File
@@ -14,7 +14,8 @@ const AVATAR_SCALE = 0.75;
<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="avatar ? 'currentColor' : ''" fill-rule="evenodd" :height="size" style="flex: none; line-height: 1;"
<svg :fill="avatar || !color ? '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>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'Ollama';
const AVATAR_SCALE = 0.75;
const BACKGROUND_COLOR = "#fff";
</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 :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">
<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" />
</svg>
</div>
</template>
-55
View File
@@ -1,55 +0,0 @@
<script setup lang="ts">
import { h, resolveComponent } from 'vue';
const props = defineProps<{ node: any }>();
const render = () => {
const { node } = props;
if (node.type === 'text' || node.type === 'raw') return node.value;
if (node.type === 'element') {
if (node.tagName === 'code') {
const isBlock = node.position?.start.line !== node.position?.end.line;
if (isBlock && node.children?.[0]?.type === 'text') {
return h(resolveComponent('MarkdownShikiHighlight'), {
code: node.children[0].value,
lang: node.properties?.className?.[0]?.replace('language-', '') || 'text'
});
}
}
if (node.tagName === 'table') {
return h('div', { class: 'table-wrapper' }, [
h(
'table',
node.properties,
node.children?.map((child: any, index: number) =>
h(resolveComponent('MarkdownAstNode'), {
node: child,
key: `table-child-${index}`
})
)
)
]);
}
return h(
node.tagName,
node.properties,
node.children?.map((child: any, index: number) =>
h(resolveComponent('MarkdownAstNode'), {
node: child,
key: `${node.tagName}-${index}`
})
)
);
}
return null;
};
</script>
<template>
<component :is="render" />
</template>
+30 -280
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { RootContent } from 'hast';
import { h, Text, computed } from 'vue';
import MarkdownShikiHighlight from './ShikiHighlight.vue';
const props = defineProps<{
@@ -10,292 +10,42 @@ const props = defineProps<{
const { $remark } = useNuxtApp();
function splitMarkdown(markdown: string): string[] {
const paragraphs: string[] = [];
let currentParagraph = "";
let isInCodeBlock = false;
const lines = markdown.split("\n");
for (let line of lines) {
if (line.trim().startsWith("```")) {
isInCodeBlock = !isInCodeBlock;
}
if (line.trim() === "" && !isInCodeBlock) {
if (currentParagraph.trim() !== "") {
paragraphs.push(currentParagraph.trim());
currentParagraph = "";
}
} else {
currentParagraph += (currentParagraph === "" ? "" : "\n") + line;
}
}
if (currentParagraph.trim() !== "") {
paragraphs.push(currentParagraph.trim());
}
return paragraphs;
}
const partseAst = async (content: string) => {
const mdast = $remark.parse(content);
const hast = $remark.run(mdast);
return hast;
}
// SSR Initial Load
const { data: hastParts } = await useAsyncData(`md-${props.id}`, async () => {
return (await partseAst(props.content)).children;
const ast = computed(() => {
const mdast = $remark.parse(props.content);
return $remark.runSync(mdast);
});
let activeIdx = 0;
let partIdx = [0];
const renderNode = (node: any, index: number): any => {
if (node.type === 'text' || node.type === 'raw') return h(Text, node.value);
if (import.meta.client && hastParts.value && hastParts.value.length > 0 && !props.finished) {
const initialParts = splitMarkdown(props.content);
if (node.type === 'element') {
if (node.tagName === 'code') {
const isBlock = node.position?.start.line !== node.position?.end.line;
if (isBlock && node.children?.[0]?.type === 'text') {
return h(MarkdownShikiHighlight, {
key: `code-${index}`,
code: node.children[0].value,
language: node.properties?.className?.[0]?.replace('language-', '') || 'text'
});
}
}
activeIdx = Math.max(0, initialParts.length - 1);
const children = node.children?.map((child: any, i: number) => renderNode(child, i)) || [];
let currentOffset = 0;
for (let i = 0; i < initialParts.length; i++) {
partIdx[i] = currentOffset;
const tempAst = await partseAst(initialParts[i]!);
currentOffset += tempAst.children.length;
return h(
node.tagName,
{ ...node.properties, key: `${node.tagName}-${index}` },
children
);
}
}
return null;
};
defineRender(() => {
const children = ast.value?.children?.flatMap(renderNode) || [];
const parts = computed(() => {
return splitMarkdown(props.content);
})
watch(parts, async (newParts) => {
if (!hastParts.value) hastParts.value = [];
while (activeIdx < newParts.length - 1) {
const finalHast = await partseAst(newParts[activeIdx]!);
const base: RootContent[] = hastParts.value!.slice(0, partIdx[activeIdx]);
hastParts.value = base.concat(finalHast.children);
partIdx[activeIdx + 1] = hastParts.value!.length;
activeIdx++;
}
const currentString = newParts[activeIdx];
if (currentString !== undefined) {
const latestHast = await partseAst(currentString);
const stableBase = hastParts.value!.slice(0, partIdx[activeIdx]);
hastParts.value = stableBase.concat(latestHast.children);
}
return h('div', { class: 'prose-wrapper' }, [
h('article', { class: 'markdown-body' }, children)
]);
})
</script>
<template>
<div class="prose-wrapper">
<article class="markdown-body">
<template v-for="(node, index) in hastParts" :key="`${id}-${index}`">
<MarkdownAstNode :node="node" v-memo="[node.type === 'text' ? node.value : node.data]" />
</template>
</article>
</div>
</template>
<style>
article>* {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
article>*:first-child {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
article>*:last-child {
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
article>*:only-child {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
hr {
border: 1px solid var(--color-highlight-high);
}
li {
min-height: 24px;
}
ul {
list-style: none;
margin-left: 1.25rem;
margin-top: 1.25rem;
}
ul>li {
position: relative;
padding-bottom: 0.75rem;
padding-left: 1.5rem;
}
ul>li::before {
content: "";
position: absolute;
left: 0;
top: 0.5rem;
width: 7px;
height: 7px;
background-color: var(--color-muted);
border-radius: 50%;
z-index: 2;
}
ul>li::after {
content: "";
position: absolute;
left: 3px;
top: 23px;
bottom: 0;
width: 1px;
background-color: var(--color-highlight);
z-index: 1;
}
ul>li:last-child::after {
display: none;
}
ol {
list-style: none;
margin-left: 1.25rem;
margin-top: 1.25rem;
counter-reset: ordered-list-counter var(--start-value, 0);
}
ol[start] {
--start-value: calc(attr(start type(<number>)) - 1);
}
ol>li {
position: relative;
padding-bottom: 0.75rem;
padding-left: 1.5rem;
counter-increment: ordered-list-counter;
}
ol>li::before {
content: counter(ordered-list-counter) ".";
position: absolute;
left: 0;
top: 0;
color: var(--color-muted);
font-weight: 500;
width: 1.25rem;
}
html.dark .shiki,
html.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
ol:only-child,
ul:only-child {
margin-top: 0 !important;
}
code:not(pre code) {
background-color: var(--color-highlight);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
blockquote {
color: var(--color-muted);
border-left: 4px solid var(--color-highlight-high);
padding-left: 0.5rem;
}
.table-wrapper {
display: block;
overflow-x: auto;
margin: calc(var(--spacing) * 4) 0;
}
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
text-align: left;
background-color: var(--color-base);
color: var(--color-text);
}
table thead tr {
background-color: var(--color-highlight-high);
}
table th {
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.05em;
}
table td {
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
}
table tbody tr {
background-color: var(--color-highlight);
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
}
table tbody tr:nth-of-type(even) {
background-color: var(--color-highlight-low);
}
/* Hover effect */
table tbody tr:hover {
background-color: var(--color-highlight-high);
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
label>span {
cursor: text;
}
hr {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
.checkbox {
width: min-content;
}
</style>
+35 -69
View File
@@ -1,17 +1,24 @@
<script lang="ts" setup>
import { type Grammar } from 'shiki';
import { hashSync } from '~/utils/hash';
const props = defineProps<{ code: string; lang: string }>();
const props = defineProps<{ code: string; language: string }>();
const renderId = hashSync(props.code + props.lang);
const codeBlockRef = ref<HTMLDivElement | null>(null);
const codeHeight: Ref<string | number> = ref('auto');
const start = Date.now();
const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
const copied = ref(false);
const collapsed = ref(false);
const { data: parsed } = useAsyncData(`shiki-${renderId}`, async () => {
const { html, displayLang } = await parseCode();
return { html, displayLang };
});
const { $shiki } = useNuxtApp();
const { data: parsed, clear } = await useAsyncData(`shiki-${renderId}`,
() => parseCode(props.code, props.language.toLowerCase()),
{
watch: [() => props.code],
dedupe: 'defer'
}
);
const lineNumberWidth = computed(() => {
if (!parsed.value?.html) return 1;
// Count newlines in the generated HTML or the source code
@@ -19,25 +26,18 @@ const lineNumberWidth = computed(() => {
return props.code.split('\n').length.toString().length;
});
watch(() => props.code, async () => {
const { html: codeHtml } = await parseCode();
parsed.value = { html: codeHtml, displayLang: parsed.value!.displayLang };
});
async function parseCode() {
const shiki = await getShikiHighlighter();
let lang = props.lang.toLowerCase();
async function parseCode(code: string, lang: string) {
let displayLang = lang;
try {
const shikiLang = shiki.getLanguage(lang);
displayLang = shikiLang.name;
let shikiLang = await $shiki.getLanguage(lang);
displayLang = (shikiLang as unknown as Grammar).name;
} catch {
lang = 'text';
}
const html = shiki.codeToHtml(props.code.trim(), {
let html = await $shiki.codeToHtml(code.trim(), {
lang,
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
});
@@ -57,81 +57,47 @@ function copyCode() {
}, 2000);
}
function collapseCode() {
if (!codeBlockRef.value) return;
collapsed.value = !collapsed.value;
if (collapsed.value) {
codeHeight.value = codeBlockRef.value.scrollHeight;
nextTick(() => {
// since we are changing the height of an element, even though its
// to its own height, we are triggering a reflow, which means that
// if we didnt use requestAnimationFrame, the height would be set
// to 0 before the reflow is complete, which would cause the reflow
// to be ignored, and another one to be triggered with the new height
// of zero, causing the codeblock to snap shut immediately rather than
// animating. Not requestAnimationFrame because it doesnt work
// on firefox, but setTimeout works on both chrome and firefox
setTimeout(() => {
codeHeight.value = 0;
});
});
} else {
codeBlockRef.value!.addEventListener('transitionend', () => {
if (collapsed.value) return;
codeHeight.value = 'auto';
}, { once: true })
const targetHeight = codeBlockRef.value.scrollHeight;
codeHeight.value = targetHeight;
}
}
const codeStyle = computed(() => {
if (typeof codeHeight.value === 'number') {
return `height: ${codeHeight.value}px;`;
} else {
return `height: ${codeHeight.value};`;
}
});
onUnmounted(() => {
if (copyTimeout) clearTimeout(copyTimeout);
clear();
});
console.log("shiki codeblock rendered in", Date.now() - start);
</script>
<template>
<div class="flex flex-col my-2 rounded-xl overflow-hidden">
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-highlight)] justify-between">
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-hover)] justify-between">
<div class="capitalize">
{{ parsed?.displayLang }}
</div>
<div class="flex gap-2">
<button @click="copyCode()"
class="flex items-center px-1 gap-0.5 rounded-md hover:bg-[var(--color-highlight)] 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)]">
Copy
<Icon v-if="!copied" name="mynaui:copy" class="text-4 text-[var(--color-text-subtle)]" />
<Icon v-if="!copied" name="mynaui:copy" class="text-4 text-[var(--text-secondary)]" />
<Icon v-else name="mynaui:check" class="text-4 text-emerald-500" />
</button>
<button @click="collapseCode()"
class="flex items-center justify-center h-5.5 w-5.5 rounded-md hover:bg-[var(--color-highlight)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<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)]">
<Icon name="mynaui:chevron-down"
:class="['text-4 h-4 w-4 text-[var(--color-text-subtle)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', collapsed ? '-rotate-90' : '']" />
:class="['text-4 h-4 w-4 text-[var(--text-secondary)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', collapsed ? '-rotate-90' : '']" />
</button>
</div>
</div>
<div ref="codeBlockRef"
class="font-mono overflow-hidden code-container transition-height duration-300 ease-in-out"
:style="`--line-number-width: ${lineNumberWidth}ch; ${codeStyle}`" :id="`code-${renderId}`"
v-html="parsed?.html">
<div class="grid transition-all duration-350 ease-in-out"
:class="collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'"
:style="`--line-number-width: ${lineNumberWidth}ch;`" :id="`code-${renderId}`">
<div class="overflow-hidden code-container" v-html="parsed?.html"></div>
</div>
</div>
</template>
<style>
.code-container>pre {
overflow-x: auto;
overflow: auto hidden;
min-height: 0;
scrollbar-width: thin;
padding: 1rem;
line-height: 0;
+1 -1
View File
@@ -19,6 +19,6 @@ if (props.error !== null && props.error !== undefined) {
<template>
<span class="text-sm text-[var(--color-error)]">Generation failed</span>
<div class="text-sm">
<ShikiHighlight :code="code ?? 'An unknown error occurred'" lang="json" />
<ShikiHighlight :code="code ?? 'An unknown error occurred'" language="json" />
</div>
</template>
+18 -28
View File
@@ -51,35 +51,25 @@ const toggleReasoning = async () => {
</script>
<template>
<button @click="toggleReasoning" :class="[
'w-full hover:bg-[var(--color-highlight)] rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
part.finished ? '' : 'cursor-default'
]">
<div class="flex items-center gap-1">
<div
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
<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)]',
part.finished ? '' : 'cursor-default'
]">
<div class="flex items-center gap-1">
<div
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
</div>
Deep Thinking
</div>
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
</button>
<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" />
</div>
Deep Thinking
</div>
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
</button>
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
:class="['reasoning-contaizner p-2 text-[--color-reasoning] max-h-[min(40vh,320px)] overflow-y-auto', scrollState]">
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
</div>
</template>
<style>
.reasoning-contaizner.middle {
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.top {
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
}
.reasoning-contaizner.bottom {
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
}
</style>
+7 -7
View File
@@ -19,7 +19,7 @@ const indicatorStyle = computed(() => {
};
});
const shiki = await getShikiHighlighter();
const { $shiki } = useNuxtApp();
const html = ref('');
const lineNumberWidth = ref(1);
@@ -87,15 +87,15 @@ const input: ComputedRef<string> = computed(() => {
watch(
input,
(newCode) => {
async (newCode) => {
let lang = 'json';
try {
shiki.getLanguage(lang);
$shiki.getLanguage(lang);
} catch (e) {
lang = 'text';
}
html.value = shiki.codeToHtml(newCode, {
html.value = await $shiki.codeToHtml(newCode, {
lang,
themes: {
dark: 'vitesse-dark',
@@ -109,8 +109,8 @@ watch(
</script>
<template>
<div class="w-full border rounded-lg border-[var(--color-highlight)] flex flex-row h-80 overflow-hidden">
<div class="flex items-center gap-2 flex-col border-r border-[var(--color-highlight)] p-1 relative shrink-0">
<div class="w-full border rounded-lg border-[var(--color-border)] flex flex-row h-80 overflow-hidden">
<div class="flex items-center gap-2 flex-col border-r border-[var(--color-border)] p-1 relative shrink-0">
<button @click="activeTab = 'input'" :class="[
'function-call-tab-selector',
activeTab === 'input' ? 'text-orange-6' : ''
@@ -207,7 +207,7 @@ watch(
}
.function-call-tab-selector:hover:enabled {
background-color: var(--color-highlight);
background-color: var(--color-hover);
}
.function-call-tab-selector:disabled {
+6 -6
View File
@@ -17,17 +17,17 @@ const toggleDebugToolCall = () => {
<template>
<div class="flex flex-col gap-2">
<div
class="select-none w-full hover:bg-[var(--color-highlight)] group rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] 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
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<!-- Explicityly avoid setting the name via a reactive value, otherwise you risk corrupting the icon with that name globally -->
<Icon v-if="toolCall.status === 'pending'" name="svg-spinners:180-ring-with-bg"
class="w-3 h-3 text-[var(--color-subtle)]" />
class="w-3 h-3 text-[var(--text-secondary)]" />
<Icon v-else-if="toolCall.status === 'failed'" name="mynaui:x-solid"
class="w-3 h-3 text-[#ff3b3b]" />
<Icon v-else name="mynaui:tool" class="w-3 h-3 text-[var(--color-subtle)]" />
<Icon v-else name="mynaui:tool" class="w-3 h-3 text-[var(--text-secondary)]" />
</div>
{{ toolCall.toolName }}
</div>
@@ -35,8 +35,8 @@ 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-highlight)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Icon name="mynaui:search" class="w-3 h-3 text-[var(--color-subtle)]" />
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)]">
<Icon name="mynaui:search" class="w-3 h-3 text-[var(--text-secondary)]" />
</button>
</div>
</div>
+2 -2
View File
@@ -25,7 +25,7 @@ defineProps<{
<span class="flex items-center gap-1"
v-if="message.generation?.status === 'pending' && (message.parts || []).length === 0">
<Icon name="svg-spinners:pulse-2" class="text-4" />
<span class="text-sm text-[var(--color-muted)]">
<span class="text-sm text-[var(--text-secondary)]">
Preparing generating...
</span>
</span>
@@ -34,7 +34,7 @@ defineProps<{
<Error :error="message.generation.error" />
</div>
<div class="flex flex-row justify-between text-zinc-400 dark:text-zinc-600 text-xs"
<div class="flex flex-row justify-between text-[var(--text-tertiary)] text-xs"
v-if="message.generation && message.generation.status !== 'pending'">
<span class="flex items-center gap-1">
<ModelIcon :size="12" :model-id="message.generation.modelId" />
+1 -1
View File
@@ -8,6 +8,6 @@ defineProps<{
</script>
<template>
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--color-highlight)] py-2 px-3 rounded-xl"
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl"
:content="message.content!" :id="message.id" />
</template>
+6 -6
View File
@@ -119,7 +119,7 @@ const messageCount = computed(() => {
<button :inert="focusedIndex === 0" @click="focusedIndex = focusedIndex! - 1">
<Icon name="mynaui:chevron-left" :class="['w-4 h-4', focusedIndex === 0 ? 'opacity-0' : '']" />
</button>
<span class="text-xs text-[var(--color-muted)]">
<span class="text-xs text-[var(--text-secondary)]">
{{ focusedIndex + 1 }} / {{ messageCount }}
</span>
<button :inert="focusedIndex + 1 === messageCount" @click="focusedIndex = focusedIndex + 1">
@@ -129,18 +129,18 @@ const messageCount = computed(() => {
</div>
</div>
<div :class="activeMessage.generation?.status === 'pending' ? 'opacity-0!' : ''"
class="self-end mt-1 w-fit bg-[var(--color-highlight)] text-[var(--color-subtle)] 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)]">
<div v-show="activeMessage.generation?.status !== 'pending'"
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)]">
<button @click="regenerateMessage"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-highlight)]">
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
<Icon name="mynaui:refresh" class="text-4.5" />
</button>
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-highlight)]">
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
<Icon :name="copied ? 'mynaui:check' : 'mynaui:copy'" class="text-4.5" />
</button>
<button @click="deleteMessage"
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-highlight)]">
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-hover)]">
<Icon name="mynaui:trash" class="text-5" />
</button>
</div>
+2 -2
View File
@@ -17,8 +17,8 @@ const config = computed(() => getModelConfig(props.modelId));
v-bind="config.props" />
<!-- Fallback if no logo matches -->
<div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }"
class="bg-[var(--color-highlight)] rounded-md flex items-center justify-center">
<Icon name="tabler:brain" class="text-5 text-[var(--color-muted)]" />
class="bg-[var(--bg-container)] rounded-md flex items-center justify-center">
<Icon name="tabler:brain" class="text-5 text-[var(--text-secondary)]" />
</div>
</div>
</template>
+15 -29
View File
@@ -19,8 +19,6 @@ const props = withDefaults(defineProps<{
showEdit: false,
});
const { Big } = await import('big.js');
const triplit = useTriplitClient();
const formatContextWindow = (window: number | null | undefined): string => {
@@ -38,15 +36,6 @@ const hasInputModality = (modality: string): boolean => {
return (props.model.attributes.inputModalities as Readonly<Set<string>>).has(modality);
};
const formatBig = (bigValue: Big) => {
let str = bigValue.toString();
if (!str.includes('.')) return str + '.00';
if (str.split('.')[1]!.length === 1) return str + '0';
return str;
};
const showCost = computed(() => {
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
});
@@ -68,16 +57,16 @@ const deleteModel = async () => {
<ModelIcon :class="{
'rounded-lg overflow-hidden': size === 'large',
'rounded-md overflow-hidden': size === 'medium' || size === 'small',
}" :avatar="true" variant="color" :model-id="model.externalId"
}" :avatar="true" variant="color" :key="model.externalId" :model-id="model.externalId"
:size="size === 'small' ? '20' : size === 'medium' ? '26' : '32'" />
<div class="flex flex-col gap-1 min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0">
<span class="text-[15px] font-medium text-[var(--color-text)] truncate min-w-0">
<span class="text-[15px] font-medium text-[var(--text-primary)] truncate min-w-0">
{{ model.name }}
</span>
<span v-if="showExternalId"
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)] whitespace-nowrap truncate max-w-[240px]">
<span v-if="showExternalId" :title="model.externalId"
class="text-xs text-[var(--text-secondary)] px-1 py-0.5 rounded bg-[var(--bg-container)] whitespace-nowrap truncate max-w-[240px]">
{{ model.externalId }}
</span>
<div v-if="showEdit" class="flex items-center gap-2">
@@ -88,25 +77,22 @@ const deleteModel = async () => {
</div>
</div>
<div v-if="details" class="flex items-center gap-1.5 flex-wrap">
<span v-if="showReleaseDate && model.releasedAt"
class="text-xs text-[var(--color-muted)] whitespace-nowrap">
<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] }}
</span>
<template v-if="showCost">
<span v-if="model.cost.prompt"
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
${{ formatBig(Big(model.cost.prompt).mul(1000000)) }}/M input
<span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
${{ model.cost.prompt }}/M input
</span>
<span v-if="model.cost.completion"
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
${{ formatBig(Big(model.cost.completion).mul(1000000)) }}/M output
<span v-if="model.cost.completion" class="text-xs whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
${{ model.cost.completion }}/M output
</span>
<span v-if="model.cost.request && model.cost.request !== '0'"
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
class="text-xs whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
{{ model.cost.request }}/request
</span>
</template>
@@ -131,7 +117,7 @@ const deleteModel = async () => {
</div>
<span v-if="model.attributes.contextWindow"
class="text-xs font-mono text-[var(--color-subtle)] px-1.5 py-0.5 rounded bg-[var(--color-highlight)]">
class="text-xs font-mono text-[var(--text-secondary)] px-1.5 py-0.5 rounded bg-[var(--bg-container)]">
{{ formatContextWindow(model.attributes.contextWindow) }}
</span>
+123 -47
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import { ref, computed, watch, nextTick } from 'vue';
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort';
const { setPage } = useSettings();
const { allModels } = await useModels();
@@ -20,6 +21,9 @@ const searchInputRef = ref<HTMLInputElement | null>(null);
const dropdownDirection = ref<'up' | 'down'>('up');
const dropdownMaxHeight = ref<number | undefined>(undefined);
const navigatingWithKeyboard = ref(false);
const focusedOptionId = ref<string | null>(null);
const findContainer = (startingElement: HTMLElement): HTMLElement | null => {
let container: HTMLElement | null = startingElement;
while (container) {
@@ -58,9 +62,41 @@ const filteredProviders = computed(() => {
return filterProvidersWithModel(props.providers, searchQuery.value).filter(p => p.enabled);
});
const flatOptions = computed(() => {
const options: { id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> }[] = [];
for (const provider of filteredProviders.value) {
const enabledModels = provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0);
for (const model of enabledModels) {
options.push({ id: model.id, model, provider });
}
}
return options;
});
const focusedIndex = computed(() => {
if (!focusedOptionId.value) return -1;
return flatOptions.value.findIndex(o => o.id === focusedOptionId.value);
});
const setFocusToOption = (index: number) => {
if (flatOptions.value.length === 0) return;
navigatingWithKeyboard.value = true;
const clampedIndex = Math.max(0, Math.min(index, flatOptions.value.length - 1));
focusedOptionId.value = flatOptions.value[clampedIndex]!.id;
scrollFocusedIntoView();
};
const scrollFocusedIntoView = () => {
nextTick(() => {
const focusedEl = document.getElementById(`model-option-${focusedOptionId.value}`);
focusedEl?.scrollIntoView({ block: 'nearest' });
});
};
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
selectedModel.value = { ...model, provider };
isOpen.value = false;
closeDropdown();
};
watch(() => props.providers, () => {
@@ -89,34 +125,69 @@ watch(isOpen, (open) => {
nextTick(() => {
searchInputRef.value?.focus();
});
} else {
focusedOptionId.value = null;
searchQuery.value = '';
}
});
const handleInputKeypress = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
isOpen.value = false;
}
}
const closeDropdown = () => {
isOpen.value = false;
navigatingWithKeyboard.value = false;
};
const handleKeyDown = (event: KeyboardEvent) => {
// TODO: in the settings page, pressing escape closes BOTH
// the settings modal and the model selector dropdown
if (event.key === 'Escape') {
isOpen.value = false;
const handleSearchKeyDown = (event: KeyboardEvent) => {
if (flatOptions.value.length === 0) return;
switch (event.key) {
case 'Escape':
closeDropdown();
event.preventDefault();
break;
case 'ArrowDown':
event.preventDefault();
if (focusedIndex.value === -1 || focusedIndex.value === flatOptions.value.length - 1) {
setFocusToOption(0);
} else {
setFocusToOption(focusedIndex.value + 1);
}
break;
case 'ArrowUp':
event.preventDefault();
if (focusedIndex.value === -1 || focusedIndex.value === 0) {
setFocusToOption(flatOptions.value.length - 1);
} else {
setFocusToOption(focusedIndex.value - 1);
}
break;
case 'Home':
event.preventDefault();
setFocusToOption(0);
break;
case 'End':
event.preventDefault();
setFocusToOption(flatOptions.value.length - 1);
break;
case 'Enter':
event.preventDefault();
console.log("enter", focusedOptionId.value, flatOptions.value[0]);
if (focusedOptionId.value) {
const option = flatOptions.value.find(o => o.id === focusedOptionId.value);
if (option) {
selectModel(option.model, option.provider);
break;
}
}
if (flatOptions.value.length > 0) {
selectModel(flatOptions.value[0]!.model, flatOptions.value[0]!.provider);
}
break;
}
};
useClickOutside(dropdownRef, () => {
isOpen.value = false;
});
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
});
useClickOutside(dropdownRef, closeDropdown);
</script>
<template>
@@ -125,8 +196,8 @@ onUnmounted(() => {
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
:class="[
isOpen
? 'bg-[var(--color-highlight)] text-[var(--color-text)]'
: 'text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)]',
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: '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" />
@@ -142,53 +213,58 @@ onUnmounted(() => {
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all 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-show="isOpen" ref="dropdownContentRef" :class="[
dropdownDirection === 'up' ? 'bottom-full mb-2 origin-bottom' : 'top-full mt-2 origin-top',
]" :style="{ maxHeight: dropdownMaxHeight ? `${dropdownMaxHeight}px` : '460px', height: 'auto' }"
class="absolute left-0 max-w-[420px] w-full flex flex-col rounded-xl border border-[var(--color-highlight)] bg-[var(--color-neutral)] shadow-lg overflow-hidden z-50">
<div v-if="isOpen" ref="dropdownContentRef" role="listbox" aria-label="Select model"
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :class="[
dropdownDirection === 'up' ? 'bottom-full mb-2 origin-bottom' : 'top-full mt-2 origin-top',
]" :style="{ maxHeight: dropdownMaxHeight ? `${dropdownMaxHeight}px` : '460px', height: 'auto' }"
class="absolute left-0 max-w-[420px] w-full flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden z-50">
<div>
<div class="relative">
<Icon name="mynaui:search"
class="absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--color-text-subtle)]" />
class="absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]" />
<input ref="searchInputRef" v-model="searchQuery" autocomplete="off" name="search"
@keypress="handleInputKeypress" type="text" placeholder="Search models..."
class="w-full pl-9 pr-3 py-2 text-sm text-[var(--color-text)] bg-transparent placeholder-[var(--color-text-subtle)] outline-none" />
@keydown="handleSearchKeyDown" type="text" placeholder="Search models..."
class="placeholder:text-[var(--text-tertiary)] w-full pl-9 pr-3 py-2 text-sm text-[var(--text-primary)] bg-transparent placeholder-[var(--text-secondary)] outline-none" />
</div>
</div>
<div class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none max-w-full overflow-hidden">
<div v-if="filteredProviders.length === 0"
class="px-4 py-8 text-center text-sm text-[var(--color-muted)]">
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
No models found
</div>
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
<div
class="px-4 py-1.5 text-[13px] font-medium text-[var(--color-muted)] capitalize tracking-wider flex justify-between">
<div v-if="provider.models.filter(m => m.enabled).length > 0"
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] capitalize tracking-wider flex justify-between">
{{ provider.name }}
<button @click="isOpen = true; setPage('providers', provider.id)"
class="flex h-4.5 w-4.5 items-center justify-center hover:bg-[var(--color-highlight)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Icon name="mynaui:cog-four" class="text-3.5" />
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)]">
<Icon name="mynaui:cog-four" class="text-4" />
</button>
</div>
<button
v-for="model in provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)"
:key="model.id" @click="selectModel(model, provider); isOpen = false"
class="text-white w-full min-h-9 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-highlight-low)] transition-colors duration-150"
:class="{ 'bg-[var(--color-highlight-low)]': selectedModel?.id === model.id }">
<button v-for="model in provider.models.filter(m => m.enabled).sort(sortByReleaseDate)"
:key="model.id" :id="`model-option-${model.id}`" role="option"
:aria-selected="focusedOptionId === model.id"
@click="selectModel(model, provider); isOpen = false"
class="text-white w-full min-h-9 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 === model.id,
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === model.id && navigatingWithKeyboard
}">
<ModelInfo :model="model" size="medium" />
</button>
</div>
</div>
<div class="p-1 border-t border-[var(--color-highlight-low)]">
<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(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)] 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="isOpen = false; setPage('providers');">
<Icon name="mynaui:cog-four" class="text-4" />
<Icon name="mynaui:cog-four" class="text-4.5" />
<span>Manage Providers</span>
<Icon name="mynaui:arrow-right" class="text-3.5 ml-auto" />
<Icon name="mynaui:arrow-right" class="text-4 ml-auto" />
</button>
</div>
</div>
+48 -144
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { providerBaseUrls, SupportedModalities, type Model } from '~/types/model';
import { providerBaseUrls, type Model } from '~/types/model';
import { useSettings } from '~/composables/useSettings';
import ModelItem from './ModelItem.vue';
// @ts-ignore
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const triplit = useTriplitClient();
const { pageParams } = useSettings();
@@ -96,29 +96,17 @@ const updateProxyUrl = async (value: string) => {
const fetchingModels = ref(false);
const fetchModels = async () => {
const { user } = useAuth();
const { user } = useAuth()
fetchingModels.value = true;
try {
const [providerResponse, devDataResponse] = await Promise.all([
$fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'POST',
body: JSON.stringify({
providerApiKey: apiKey.value
})
}) as any,
$fetch('https://models.dev/api.json') as any
]);
// TODO: get model details correctly for ollama-cloud models
let providerType = provider.value!.type as string;
if (providerType === 'ollama') {
providerType = 'ollama-cloud';
}
const modelDetails = devDataResponse[providerType]?.models || {};
console.log(modelDetails);
const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'POST',
body: JSON.stringify({
providerApiKey: apiKey.value
})
}) as any;
const existingModelsMap = new Map(
(provider.value?.models || []).map((m: any) => [m.externalId, m])
@@ -127,103 +115,34 @@ const fetchModels = async () => {
const toInsert: any[] = [];
const toUpdate: { id: string, data: any }[] = [];
providerResponse.models.forEach((pModel: any) => {
let slug: string = pModel.id.toLowerCase();
if (providerType === 'ollama-cloud') {
slug = slug.replace(/:cloud$/, '');
slug = slug.replace(/-cloud$/, '');
slug = slug.replace(/:latest$/, '');
}
let info = modelDetails[slug] || {};
const capabilities = [];
if (info.reasoning) {
capabilities.push('reasoning');
}
if (info.tool_call) {
capabilities.push('tools');
}
let inputModalities = info.modalities?.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
if (inputModalities === undefined || inputModalities.length === 0) {
inputModalities = ['text'];
}
let outputModalities = info.modalities?.output.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
if (outputModalities === undefined || outputModalities.length === 0) {
outputModalities = ['text'];
}
// merge pModel.attributes and info.modalities, with a preference for pModel.attributes
const attributes = {
inputModalities: new Set(inputModalities),
outputModalities: new Set(outputModalities),
capabilities,
contextWindow: pModel.context_length || info.limit?.context || null,
supported_parameters: new Set(pModel.supported_parameters || ["temperature", "max_tokens"]),
...(pModel.attributes || {}),
};
let cost;
if (pModel.pricing === undefined) {
cost = {}
} else {
cost = {
prompt: pModel.pricing.prompt || null,
completion: pModel.pricing.completion || null,
request: pModel.pricing.request || null,
image: pModel.pricing.image || null,
imageTokens: pModel.pricing.image_tokens || null,
imageOutput: pModel.pricing.image_output || null,
audio: pModel.pricing.audio || null,
audioOutput: pModel.pricing.audio_output || null,
inputAudioCache: pModel.pricing.input_audio_cache || null,
webSearch: pModel.pricing.web_search || null,
internalReasoning: pModel.pricing.internal_reasoning || null,
inputCacheRead: pModel.pricing.input_cache_read || null,
inputCacheWrite: pModel.pricing.input_cache_write || null,
discount: pModel.pricing.discount || null,
}
}
const existing = existingModelsMap.get(pModel.id);
for (const model of modelsData.models) {
const existing = existingModelsMap.get(model.id);
if (existing) {
// UPDATE logic: Remove 'id' from the payload as per Triplit requirements
const { id, ...existingWithoutId } = existing;
const { id, ...existingWithoutId } = model;
console.log("existingWithoutId", existingWithoutId);
toUpdate.push({
id: existing.id,
data: {
...existingWithoutId,
name: existing.name || pModel.name || info.name || pModel.id,
cost,
attributes, // Update tech specs
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
}
data: existingWithoutId,
});
} else {
// INSERT logic: This is a brand new model
toInsert.push({
userId: user.value?.id,
providerId: provider.value!.id,
externalId: pModel.id,
name: pModel.name || info.name || pModel.id,
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,
cost,
attributes,
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
createdAt: new Date(),
releasedAt: model.releasedAt,
});
}
});
}
// delete models that are not in the API response and are not custom models
const apiModelIds = new Set(providerResponse.models.map((p: any) => p.id));
const apiModelIds = new Set(existingModelsMap.values().map((m: any) => m.externalId));
const toDelete = (provider.value?.models || []).filter((m: any) =>
!m.isCustom && !apiModelIds.has(m.externalId)
);
@@ -250,12 +169,12 @@ const deleteModels = async () => {
const enabledModels = computed(() =>
filterModels(provider.value?.models.filter(m => m.enabled === true) as Model[] || [], modelSearch.value)
.sort((a, b) => a.releasedAt && b.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)
.sort(sortByReleaseDate)
)
const disabledModels = computed(() =>
filterModels(provider.value?.models.filter(m => m.enabled === false) as Model[] || [], modelSearch.value)
.sort((a, b) => a.releasedAt && b.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)
.sort(sortByReleaseDate)
)
onUnmounted(() => {
@@ -274,13 +193,13 @@ defineEmits(['navigate']);
<div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">API Key</label>
<div
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10">
<input class="w-full p-0 pl-2 py-1 bg-transparent" :type="apiKeyVisible ? 'text' : 'password'"
id="provider-api-key" :value="apiKey" autocomplete="false" spellcheck="false"
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
<input class="placeholder:text-[var(--text-tertiary)] w-full p-0 pl-2 py-1 bg-transparent"
:type="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :value="apiKey"
autocomplete="false" spellcheck="false"
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
<button @click="apiKeyVisible = !apiKeyVisible"
class="text-sm p-2 text-[var(--color-muted)] hover:text-[var(--color-text)]">
class="text-sm p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4 min-h-4 min-w-4" />
</button>
</div>
@@ -288,16 +207,16 @@ 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(--color-highlight)] items-center gap-1 w-7/10">
<input :placeholder="providerBaseUrls[provider!.type] ?? ''" class="w-full px-2 py-1 bg-transparent"
type="text" id="provider-proxy-url" :value="apiProxyUrl"
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
<input :placeholder="providerBaseUrls[provider!.type] ?? ''"
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)" />
</div>
</div>
<div class="flex flex-row justify-center text-xs">
<p class="text-[var(--color-muted)]">
<p class="text-[var(--text-secondary)]">
<Icon name="mynaui:lock" /> Your API key is encrypted using <a
href="https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01">AES-GCM</a> encryption.
</p>
@@ -307,26 +226,27 @@ defineEmits(['navigate']);
<div class="pt-5 justify-between w-full flex flex-wrap gap-y-1 items-center">
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
Model List
<span class="text-sm text-[var(--color-muted)] font-normal text-xs flex items-center gap-1">
{{ provider?.models.length }} models available <button @click="deleteModels">
<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"
@click="deleteModels">
<Icon name="mynaui:x-solid" />
</button>
</span>
</h4>
<div class="flex items-center gap-2">
<div
class="flex items-center justify-center px-2 py-1 bg-[var(--color-highlight)] text-xs rounded-md">
<input class="p-0 bg-transparent" v-model="modelSearch" type="text"
placeholder="Search models..." />
<div class="flex items-center justify-center px-2 py-1 bg-[var(--bg-container)] text-xs rounded-md">
<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-highlight)] rounded p-0.5">
<Icon name="mynaui:x" class="text-3.5 block text-[var(--color-subtle)]" />
class="right-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5">
<Icon name="mynaui:x" class="text-3.5 block text-[var(--text-secondary)]" />
</button>
</div>
<button @click="fetchModels"
class="whitespace-nowrap flex bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] 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)]">
<Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
fetch models
</button>
@@ -335,14 +255,14 @@ defineEmits(['navigate']);
<div v-if="provider?.models?.length === 0" class="flex flex-row items-center justify-center gap-2 mt-2">
<Icon name="mynaui:info-circle" class="text-4" />
<span class="text-sm text-[var(--color-muted)]">
<span class="text-sm text-[var(--text-secondary)]">
No models found
</span>
</div>
<ClientOnly v-else>
<div class="flex flex-col gap-1 mt-2">
<span v-if="enabledModels.length > 0" class="text-sm text-[var(--color-muted)]">
<span v-if="enabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
Enabled
</span>
<div class="flex flex-col gap-1">
@@ -359,7 +279,7 @@ defineEmits(['navigate']);
</template>
</DynamicScroller>
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--color-muted)]">
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
Disabled
</span>
<div class="flex flex-col gap-1">
@@ -383,19 +303,3 @@ defineEmits(['navigate']);
</div>
</div>
</template>
<style>
.animate-rotate {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
+80 -2
View File
@@ -1,3 +1,81 @@
<template>
<script setup lang="ts">
const { settings, updateSettings } = await useUserSettings();
const { colorScheme } = useTheme();
</template>
const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta'];
const neutrals = ['zinc', 'slate', 'obsidian'];
const updateAccent = (accent: string) => {
updateSettings({ appearance: { accent } });
};
const updateNeutral = (neutral: string) => {
updateSettings({ appearance: { neutral } });
};
const updateHinting = (e: Event) => {
const hinting = parseInt((e.target as HTMLInputElement).value);
updateSettings({ appearance: { hinting } });
};
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-6 mt-4">
<div class="flex flex-row items-center justify-between gap-2">
<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="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''">
<Icon name="tabler:device-desktop" class="text-4" />
<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="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''">
<Icon name="mynaui:moon" class="text-4" />
<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="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''">
<Icon name="mynaui:sun" class="text-4" />
<span>Light</span>
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<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="[
settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent'
]" :style="`background-color: var(--accent-${accent})`" :title="accent" />
</div>
</div>
<div class="flex flex-col gap-2">
<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="[
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>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<h4 class="text-sm font-medium">Accent Hinting</h4>
<span class="text-xs text-zinc-500">{{ settings.appearance.hinting }}%</span>
</div>
<input type="range" min="0" max="100" step="1" :value="settings.appearance.hinting" @input="updateHinting"
class="accent-[var(--color-accent)]" />
</div>
</div>
</template>
+5 -5
View File
@@ -96,14 +96,14 @@ onUnmounted(() => {
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
<div class="absolute w-[85vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
<div class="absolute w-[85vw] max-w-6xl h-[70vh] bg-[var(--bg-base)] rounded-2xl shadow-2xl border border-[var(--color-border)]
overflow-hidden flex max-h-[90vh] p-2">
<nav class="w-64 flex flex-col gap-1 mr-2 overflow-y-auto">
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="setPage" />
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setPage(id)"
:class="[currentPage === id ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
:class="[currentPage === 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">
<Icon :name="config.icon" class="w-5 h-5" />
{{ config.label }}
@@ -114,11 +114,11 @@ onUnmounted(() => {
<!-- DYNAMIC CONTENT -->
<main class="flex-1 flex flex-col overflow-hidden">
<div
class="flex flex-col flex-1 p-3 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
class="flex flex-col flex-1 p-3 bg-[var(--bg-surface)] overflow-y-auto border rounded-lg border-[var(--color-border)]">
<header class="flex items-center justify-between pl-2 pb-2 ">
<h2 class="text-lg font-semibold m-0">{{ runtimePage.label }}</h2>
<h2 class="text-lg font-semibold m-0 capitalize">{{ runtimePage.label }}</h2>
<button
class="hover:bg-[var(--color-highlight)] 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">
<Icon name="mynaui:x-solid" />
</button>
@@ -3,4 +3,5 @@ defineEmits(['navigate']);
</script>
<template>
hi
</template>
+1 -3
View File
@@ -1,6 +1,4 @@
<script lang="ts" setup>
const triplit = useTriplitClient();
const props = defineProps<{
model: ModelWithProvider;
}>();
@@ -8,7 +6,7 @@ const props = defineProps<{
<template>
<div
class="p-3 text-white flex max-w-full items-center justify-between gap-2 group hover:bg-[var(--color-highlight-low)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
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)]">
<ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
:show-release-date="true" />
</div>
+26 -10
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { Providers } from '~/types/model';
import { providerIcons } from '~/utils/model-mapping';
const triplit = useTriplitClient();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
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)) {
@@ -20,6 +22,12 @@ for (const provider of Providers) {
}
}
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;
@@ -39,7 +47,7 @@ defineEmits(['navigate']);
<template>
<div class="flex flex-col gap-1">
<h2 class="text-lg font-semibold flex items-center gap-2">
Enabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
Enabled <span class="text-sm bg-[var(--bg-container)] px-2 rounded-md py-0.5 text-[var(--text-secondary)]">
{{providers?.filter(p => p.enabled).length}}
</span>
</h2>
@@ -47,21 +55,25 @@ 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-highlight)] hover:border-[var(--color-highlight-high)]">
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">
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
<hr class="border-t border-[var(--color-highlight)]" />
<div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-8 h-8 text-[var(--text-primary)]" />
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
</div>
<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-highlight)]" /> -->
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>
</div>
<h2 class="text-lg font-semibold flex items-center gap-2">
Disabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
Disabled <span class="text-sm bg-[var(--bg-container)] px-2 rounded-md py-0.5 text-[var(--text-secondary)]">
{{providers?.filter(p => !p.enabled).length}}
</span>
</h2>
@@ -69,14 +81,18 @@ 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-highlight)] hover:border-[var(--color-highlight-high)]">
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">
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
<hr class="border-t border-[var(--color-highlight)]" />
<div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-8 h-8 text-[var(--text-primary)]" />
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
</div>
<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-highlight)]" /> -->
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>
+16 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { providerIcons } from '~/utils/model-mapping';
const { pageParams } = useSettings();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
@@ -12,28 +14,36 @@ defineEmits(['navigate']);
<template>
<div class="flex flex-col gap-1">
<button @click="$emit('navigate', 'general')"
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] 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)]">
<Icon name="mynaui:chevron-left" class="text-4" /> 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-highlight)] 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)]">
<Icon name="mynaui:envelope-open" class="text-4" /> 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="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
<span>{{ p.name }}</span>
:class="['capitalize 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 === pageParams[0] ? '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)]" />
<span>{{ p.name }}</span>
</div>
</button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div>
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
@click="$emit('navigate', 'providers', p.id)"
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
<span>{{ p.name }}</span>
:class="['capitalize 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 === pageParams[0] ? '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)]" />
<span>{{ p.name }}</span>
</div>
</button>
</div>
</template>
+15 -23
View File
@@ -1,31 +1,27 @@
<script setup lang="ts">
const triplit = useTriplitClient();
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels();
const { settings, unsubscribe: unsubscribeSettings } = await useUserSettings();
import type { ModelWithProvider } from '~/composables/useModels';
const { providers, allModels } = await useModels();
const { settings, updateSettings } = await useUserSettings();
const toggle = async (key: string) => {
console.log(key);
await triplit.update('settings', settings.value.id, {
const current = (settings.value.systemAssistants as any)[key];
await updateSettings({
systemAssistants: {
...settings.value.systemAssistants,
[key]: {
// @ts-ignore
...settings.value.systemAssistants[key],
// @ts-ignore
enabled: !settings.value.systemAssistants[key].enabled
...current,
enabled: !current.enabled
}
}
});
};
const updateModel = async (key: string, model: ModelWithProvider | undefined | null) => {
await triplit.update('settings', settings.value.id, {
const current = (settings.value.systemAssistants as any)[key];
await updateSettings({
systemAssistants: {
...settings.value.systemAssistants,
[key]: {
// @ts-ignore
...settings.value.systemAssistants[key],
...current,
modelId: model?.id ?? null
}
}
@@ -40,23 +36,19 @@ const getModel = (id: string | null | undefined) => {
defineEmits(['navigate']);
onUnmounted(() => {
unsubscribeModels?.();
unsubscribeSettings?.();
});
</script>
<template>
<div class="flex flex-col gap-4 flex-grow">
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants">
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key">
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
<h4 class="capitalize">{{ key }}</h4>
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(key)" />
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(String(key))" />
</label>
<div class="flex-1 justify-between gap-4 items-center">
<ModelSelector :providers="providers" :model-value="getModel(systemAssistant.modelId)"
@update:model-value="(model) => updateModel(key, model)" />
@update:model-value="(model) => updateModel(String(key), model)" />
</div>
</div>
</div>
</template>
</template>
+51 -29
View File
@@ -10,6 +10,9 @@ const { user, signOut } = useAuth();
// particular)
const cachedUser = ref(user.value);
const dropdownRef = ref<HTMLDivElement | null>(null);
const dropdownOpen = ref(false);
watch(user, () => {
if (user.value === null) return;
@@ -20,8 +23,6 @@ const { toggle: toggleSettings } = useSettings();
const { isHovered } = useSidenavContext();
const profileOpen = ref(false);
const handleLogout = async () => {
await signOut();
assert('disconnect' in triplit);
@@ -30,41 +31,62 @@ const handleLogout = async () => {
await navigateTo('/auth/login');
};
const profileItems: DropdownItem[] = [
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
{
label: 'Log out',
icon: 'mynaui:logout',
divider: true,
onClick: handleLogout,
},
];
useClickOutside(dropdownRef, () => {
dropdownOpen.value = false;
});
</script>
<template>
<header class="flex items-center justify-between overflow-hidden">
<Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
<header ref="dropdownRef" 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)]">
<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" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--text-secondary)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ cachedUser?.name }}
</span>
<div :class="['transform-origin-left-center flex-shrink-0 w-4 h-4 text-[var(--text-secondary)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40'
]">
<Icon class="text-4 transition-transform duration-250 ease-in-out"
:class="dropdownOpen ? 'rotate-180' : ''" name="mynaui:chevron-down" />
</div>
</button>
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="dropdownOpen"
class="w-full top-full text-sm mt-1 absolute z-50 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
<button @click="dropdownOpen = false; toggleSettings()"
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)]">
<Icon name="mynaui:cog-four" class="text-4.5" />
<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)]">
<Icon name="mynaui:logout" class="text-4.5" />
<span>Log out</span>
</button>
</div>
</Transition>
<!-- <Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
verticality="descending" width="100%">
<template #trigger="{ toggle }">
<button aria-label="open user dropdown"
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] cursor-pointer transition-colors max-w-full"
@click="toggle">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-muted)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
cachedUser?.name
}}</span>
<div :class="['transform-origin-left-center flex-shrink-0 w-4 h-4 text-[var(--color-muted)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40'
]">
<Icon class="text-4" name="mynaui:chevron-down" />
</div>
</button>
</template>
</Dropdown>
</Dropdown> -->
</header>
</template>
+53 -14
View File
@@ -1,20 +1,39 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown';
const route = useRoute();
const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const activeAgent = computed(() => getAgent(route.params.id as string));
const { isHovered } = useSidenavContext();
const { isHovered, sidebarWidth } = useSidenavContext();
onUnmounted(() => {
unsubscribeAgents?.();
});
const agentDropdownOpen = ref(false);
const toggleDropdown = (e: MouseEvent) => {
e.stopPropagation();
const agentItems: DropdownItem[] = [];
if (dropdownState.open) {
closeDropdown();
return;
}
const itemsFactory = () => {
const items = [];
for (const agent of agents.value || []) {
items.push({
label: agent.name,
icon: 'mynaui:check-hexagon',
active: activeAgent.value?.id === agent.id,
onClick: () => navigateTo(`/agent/${agent.id}`)
});
}
return items;
};
openDropdown(e, itemsFactory, { verticality: 'descending', placement: 'center', maxWidth: '200px' });
}
</script>
<template>
@@ -22,41 +41,61 @@ const agentItems: DropdownItem[] = [];
<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-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-muted)] 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">
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
</NuxtLink>
</div>
<Dropdown class="overflow-hidden" v-model="agentDropdownOpen" :items="agentItems" placement="center"
width="calc(80% - 1rem)">
<button
class="max-w-full transition duration-200 pr-2 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"
@click="toggleDropdown">
<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)]']">
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl" class="w-full h-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ activeAgent?.name }}
</span>
<div class="w-4 h-4 text-[var(--text-secondary)]">
<Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
name="mynaui:chevron-up-down" />
</div>
</div>
</button>
<!-- <Dropdown class="overflow-hidden" v-model="agentDropdownOpen" placement="center" width="calc(80% - 1rem)">
<template #trigger="{ toggle }">
<button
class="flex max-w-full gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg"
class="flex max-w-full transition duration-200 gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"
@click="toggle">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-highlight-high)]']">
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-border)]']">
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
class="w-full h-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ activeAgent?.name }}
</span>
<div class="w-4 h-4 text-[var(--color-muted)]">
<div class="w-4 h-4 text-[var(--text-secondary)]">
<Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
name="mynaui:chevron-up-down" />
</div>
</button>
</template>
<template #content>
<template #content>
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
:active="activeAgent?.id === agent.id" />
</div>
</template>
</Dropdown>
</Dropdown> -->
</header>
</template>
+5 -5
View File
@@ -9,11 +9,11 @@ const props = defineProps<{
<template>
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[
'decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
'decoration-none text-[var(--text-secondary)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
props.icon ? 'px-1' : 'px-2',
props.active
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
? '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">
@@ -30,8 +30,8 @@ const props = defineProps<{
'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(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
? '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">
+176 -137
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { assert } from '~~/utils/assert';
const route = useRoute();
const topicsListRef = ref<HTMLElement | null>(null);
const topicsListHeight = ref('auto');
const topicsListOpacity = ref(1);
const topicsListScale = ref(1);
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const triplit = useTriplitClient();
@@ -12,97 +11,88 @@ const activeAgent = computed(() => getAgent(route.params.id as string));
const topics = computed(() => {
if (activeAgent.value === undefined) return [];
// return the todos but sorted and in a new array do not add messages or anything to the object, JUST SORT IT
return activeAgent.value.topics
.map((topic) => topic)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.reverse();
});
const routeParts = computed(() => {
return route.path.replace('/agent/', '').split('/');
});
if (routeParts.value.length < 1) navigateTo('/');
const pageInfo = computed(() => {
// `/agent/agent_[uuid]` or `/agent/agent_[uuid]/topic/...`
if (route.path.startsWith('/agent/') && !route.path.includes('/profile')) {
return 'conversation';
}
// `/agent/agent_[uuid]/profile`
if (route.path.endsWith('/profile')) {
return 'agent-profile';
}
return activeAgent.value.topics;
});
const topicsOpen = ref(true);
function easeInOutQuad(x: number): number {
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
}
const toggleAgentsList = () => {
if (!topicsListRef.value) return;
const animationLength = 200;
let animationStart: number | null = null;
let startHeight: number;
const startOpacity = topicsListOpacity.value;
const startScale = topicsListScale.value;
if (topicsListHeight.value === 'auto') {
startHeight = topicsListRef.value.clientHeight;
} else {
startHeight = Number(topicsListHeight.value.replace('px', ''));
}
const targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
const targetOpacity = topicsOpen.value ? 0 : 1;
const targetScale = topicsOpen.value ? 0.95 : 1;
topicsOpen.value = !topicsOpen.value;
const animate = (timestamp: number) => {
if (!animationStart) animationStart = timestamp;
const elapsed = timestamp - animationStart;
const progress = Math.min(elapsed / animationLength, 1);
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
topicsListOpacity.value = currentOpacity;
topicsListScale.value = currentScale;
topicsListHeight.value = `${currentHeight}px`;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
if (topicsOpen.value) {
topicsListHeight.value = 'auto';
}
}
};
requestAnimationFrame(animate);
};
let activeAutoRenames = reactive(new Map<string, string>());
const autoRenameTopic = async (topicId: string) => {
const { setPage } = useSettings();
const { autoRename } = useChat(route.params.id as string);
const { autoRename, AutoRenameError } = useChat(route.params.id as string);
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
if (!firstMessage) return;
const success = await autoRename(topicId, firstMessage.content);
if (!success) {
setPage('systemAssistants');
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;
}
}
const renameTopic = (topicId: string) => {
console.log('renameTopic', topicId);
const cancelAutoRename = async (topicId: string) => {
await triplit.update('topics', topicId, {
renaming: false,
});
const renameId = activeAutoRenames.get(topicId);
if (!renameId) return;
await $fetch(`/api/topic/auto-rename/cancel/${renameId}`, {
method: 'POST',
});
activeAutoRenames.delete(topicId);
}
const renameTopicId = ref<string | null>(null);
const newTopicName = ref('');
const startRename = (topicId: string, currentName: string) => {
renameTopicId.value = topicId;
newTopicName.value = currentName;
nextTick(() => {
const input = document.getElementById('topic-rename-input') as HTMLInputElement | null;
if (input) {
input.focus();
}
})
};
const saveRename = async () => {
if (renameTopicId.value && newTopicName.value.trim()) {
await triplit.update('topics', renameTopicId.value, {
name: newTopicName.value.trim()
});
}
cancelRename();
};
const cancelRename = () => {
renameTopicId.value = null;
newTopicName.value = '';
};
const deleteTopic = async (topicId: string) => {
@@ -118,6 +108,70 @@ const deleteTopic = async (topicId: string) => {
// TODO: deeply delete all messages, generations, and message_parts in the topic
};
const handleNavClick = (e: MouseEvent) => {
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!trigger) return;
const topicId = trigger.dataset.topicId || (trigger.closest('[data-topic-id]') as HTMLElement | null)?.dataset.topicId;
if (!topicId) return;
const action = trigger.dataset.action;
if (action === 'navigate') {
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
e.preventDefault();
return navigateTo(`/agent/${route.params.id}/topic/${topicId}`);
}
if (action === 'toggle-dropdown') {
e.preventDefault();
e.stopPropagation();
if (dropdownState.open) {
closeDropdown();
return;
}
const itemsFactory = () => {
const items = [];
const isRenaming = activeAutoRenames.has(topicId) && topics.value?.find(t => t.id === topicId)?.renaming;
if (isRenaming) {
items.push({
label: 'Cancel Auto Rename',
onClick: () => cancelAutoRename(topicId)
});
} else {
items.push({
label: 'Auto Rename',
onClick: () => autoRenameTopic(topicId)
});
}
items.push({
label: 'Rename',
disabled: isRenaming ?? false,
onClick: () => startRename(topicId, topics.value?.find(t => t.id === topicId)?.name || '')
});
items.push({
label: 'Delete',
danger: true,
onClick: () => deleteTopic(topicId)
});
return items;
};
openDropdown(e, itemsFactory, { minWidth: '120px', placement: 'right' });
}
}
onMounted(() => {
// simply preload the topic page
preloadRouteComponents(`/agent/${route.params.id}/topic/42`);
})
onUnmounted(() => {
unsubscribeAgents?.();
});
@@ -128,69 +182,54 @@ onUnmounted(() => {
<!-- Agent Info Link -->
<div class="mt-2 flex flex-col">
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square"
:active="pageInfo === 'agent-profile'" />
<SidenavItem :to="`/agent/${route.params.id}/profile`" name="Agent Info" icon="mynaui:info-square"
:active="route.path.endsWith('/profile')" />
<!-- Topics Section -->
<button @click="toggleAgentsList"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left">
<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">
<span class="text-sm font-medium">Topics</span>
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
</button>
<div ref="topicsListRef" :inert="!topicsOpen" :class="{ 'overflow-y-hidden': !topicsOpen }"
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
class="mt-1 gap-1 flex flex-col transform-origin-center-top">
<NuxtLink v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
'group relative decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
'px-2',
topic.id === route.params.topicId
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div v-if="!topic.renaming" class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ topic.name }}
</span>
<Collapsible :is-open="topicsOpen">
<div @click="handleNavClick"
class="mt-1 gap-1 flex flex-col transform-origin-center-top [content-visibility:auto] [contain-intrinsic-size:0_36px]">
<a v-for="topic in topics" :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
:href="`/agent/${route.params.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
'group relative decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
'px-2',
topic.id === route.params.topicId
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div class="flex justify-between items-center w-full">
<input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
@blur="saveRename"
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
<div v-else-if="topic.renaming" class="flex w-full">
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
</div>
<span v-else
class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ topic.name }}
</span>
<Dropdown class="shrink-0 text-[var(--color-text)]" verticality="descending"
placement="right">
<template #trigger="{ toggle }">
<div dots @click.prevent="toggle"
class="opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24"><!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ -->
<path fill="currentColor"
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
</svg>
</div>
</template>
<template #content="{ toggle }">
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
<button @click.prevent="autoRenameTopic(topic.id); toggle()"
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Auto Rename
</button>
<button @click.prevent="renameTopic(topic.id); toggle()"
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Rename
</button>
<button @click.prevent="deleteTopic(topic.id); toggle()"
class="text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-600/20 rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Delete
</button>
</div>
</template>
</Dropdown>
<div data-action="toggle-dropdown"
class="shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<svg class="pointer-events-none" xmlns="http://www.w3.org/2000/svg" width="18"
height="18" viewBox="0 0 24 24">
<path fill="currentColor"
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
</svg>
</div>
</div>
</div>
<div v-else class="flex w-full">
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
</div>
</div>
</NuxtLink>
</div>
</a>
</div>
</Collapsible>
</div>
</nav>
</template>
+31 -84
View File
@@ -1,107 +1,54 @@
<script setup lang="ts">
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
const agentsListRef = ref<HTMLElement | null>(null);
const { agents, unsubscribe, createAgent } = await useAgents();
const agentsOpen = ref(true);
const agentsListHeight = ref('auto');
const agentsListOpacity = ref(1);
const agentsListScale = ref(1);
const creatingAgent = ref(false);
function easeInOutQuad(x: number): number {
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
}
const toggleAgentsList = () => {
if (!agentsListRef.value) return;
const animationLength = 200;
let animationStart: number | null = null;
let startHeight: number;
const startOpacity = agentsListOpacity.value;
const startScale = agentsListScale.value;
if (agentsListHeight.value === 'auto') {
startHeight = agentsListRef.value.clientHeight;
} else {
startHeight = Number(agentsListHeight.value.replace('px', ''));
}
const targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
const targetOpacity = agentsOpen.value ? 0 : 1;
const targetScale = agentsOpen.value ? 0.95 : 1;
agentsOpen.value = !agentsOpen.value;
const animate = (timestamp: number) => {
if (!animationStart) animationStart = timestamp;
const elapsed = timestamp - animationStart;
const progress = Math.min(elapsed / animationLength, 1);
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
agentsListOpacity.value = currentOpacity;
agentsListScale.value = currentScale;
agentsListHeight.value = `${currentHeight}px`;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
if (agentsOpen.value) {
agentsListHeight.value = 'auto';
}
}
};
requestAnimationFrame(animate);
};
const newAgent = async () => {
const triplit = useTriplitClient();
const { user } = useAuth();
if (!user.value) throw new Error('User not logged in');
const agent = await createAgent();
if (!agent) throw new Error('Failed to create agent');
return navigateTo(`/agent/${agent.id}`);
creatingAgent.value = true;
try {
const agent = await createAgent();
if (agent) return navigateTo(`/agent/${agent.id}`);
} finally {
creatingAgent.value = false;
}
};
onUnmounted(() => {
unsubscribeAgents?.();
});
onUnmounted(() => unsubscribe?.());
</script>
<template>
<nav class="flex flex-col gap-1">
<SidenavItem name="Search" icon="mynaui:search" />
<SidenavItem to="/" :active="true" name="Home" icon="mynaui:home" />
<SidenavItem to="/" name="Home" icon="mynaui:home" />
<!-- Agents Section -->
<!-- Header Toggle -->
<button
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left"
@click="toggleAgentsList()">
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>
<Icon name="mynaui:chevron-down"
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" />
<Icon name="mynaui:chevron-down" class="w-4 h-4 transition-transform duration-200"
:class="{ '-rotate-90': !agentsOpen }" />
</button>
<div ref="agentsListRef" :inert="!agentsOpen"
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
<button @click="newAgent" :disabled="creatingAgent"
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-muted)] bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
<div class="h-7 w-7 flex items-center justify-center">
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
<Icon v-else class="text-4.5" name="mynaui:plus" />
</div>
<span>New Agent</span>
</button>
<!-- Animated Section -->
<Collapsible :is-open="agentsOpen">
<div class="mt-1 gap-1 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">
<div class="h-7 w-7 flex items-center justify-center">
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
<Icon v-else class="text-4.5" name="mynaui:plus" />
</div>
<span>New Agent</span>
</button>
<SidenavItem
v-for="agent in agents?.map((agent) => agent)?.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())"
:to="`/agent/${agent.id}`" :key="agent.id" :name="agent.name" icon="mynaui:check-hexagon" />
</div>
<SidenavItem v-for="agent in agents" :key="agent.id" :to="`/agent/${agent.id}`" :name="agent.name"
icon="mynaui:check-hexagon" />
</div>
</Collapsible>
</nav>
</template>
</template>
+19 -46
View File
@@ -68,15 +68,18 @@ const onResizeEnd = () => {
let resizeAnimationFrame: number | null = null;
const trackInteractionMouse = () => trackInteraction('mouse');
const trackInteractionKeyboard = (e: KeyboardEvent) => {
if (e.key === 'Tab') trackInteraction('keyboard');
};
onMounted(() => {
document.addEventListener('mousemove', onResizeMove);
document.addEventListener('mouseup', onResizeEnd);
// NEW: Track global interactions
document.addEventListener('mousedown', () => trackInteraction('mouse'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') trackInteraction('keyboard');
});
document.addEventListener('mousedown', trackInteractionMouse);
document.addEventListener('keydown', trackInteractionKeyboard);
});
onUnmounted(() => {
@@ -84,10 +87,8 @@ onUnmounted(() => {
document.removeEventListener('mouseup', onResizeEnd);
// NEW: Cleanup listeners
document.removeEventListener('mousedown', () => trackInteraction('mouse'));
document.removeEventListener('keydown', (e) => {
if (e.key === 'Tab') trackInteraction('keyboard');
});
document.removeEventListener('mousedown', trackInteractionMouse);
document.removeEventListener('keydown', trackInteractionKeyboard);
});
const navKind = computed(() => {
@@ -100,9 +101,9 @@ const navKind = computed(() => {
<template>
<div class="relative">
<aside ref="sidenavRef" :class="[
'sidenav',
open ? 'sidenav--open' : 'sidenav--closed',
isResizing ? 'sidenav--resizing' : ''
'h-full max-w-fit background-[var(--bg-base)] overflow-hidden will-change-width text-[var(--text-secondary)] select-none transition-[width,margin] duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
open ? 'w-full mr-2' : 'w-0 mr-0',
isResizing ? 'transition-none' : ''
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
@@ -113,11 +114,11 @@ const navKind = computed(() => {
<SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
<div class="flex items-center justify-end text-[var(--color-muted)] gap-0.5">
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'"
: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']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] 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',
]">
<Icon name="mynaui:panel-left-close"
:class="['transition-inherit transform-origin-right-center', isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
@@ -125,7 +126,7 @@ const navKind = computed(() => {
</div>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
<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-highlight)] focus-visible:bg-[var(--color-highlight)] 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',
]">
<Icon name="mynaui:book-plus" />
</NuxtLink>
@@ -134,7 +135,8 @@ const navKind = computed(() => {
</div>
<!-- Main Menu -->
<div class="max-h-full h-full overflow-auto" style="scrollbar-width: thin;">
<div
class="max-h-full h-full overflow-auto [scrollbar-color:#888_transparent] [scrollbar-width:thin] [scrollbar-gutter:stable]">
<SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" />
</div>
@@ -143,12 +145,11 @@ const navKind = computed(() => {
<div class="flex justify-between pt-2">
<div class="flex">
<button @click="toggleSettings()"
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg transition-colors text-[var(--color-muted)] active:text-[var(--color-text)]">
<Icon name="mynaui:cog-four" class="text-4" />
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)]">
<Icon name="mynaui:cog-four" class="text-5" />
</button>
</div>
<!-- Theme Switcher -->
<div class="flex justify-end gap-1">
<ThemeSwitcher />
</div>
@@ -161,31 +162,3 @@ const navKind = computed(() => {
</div>
</div>
</template>
<style scoped>
.sidenav {
height: 100%;
max-width: fit-content;
background: var(--color-base);
overflow: hidden;
will-change: width;
color: var(--color-muted);
user-select: none;
transition: width 250ms cubic-bezier(0, 0.55, 0.45, 1),
margin 250ms cubic-bezier(0, 0.55, 0.45, 1);
}
.sidenav--open {
width: 100%;
margin-right: 0.5rem;
}
.sidenav--closed {
width: 0;
margin-right: 0;
}
.sidenav--resizing {
transition: none;
}
</style>
+5 -54
View File
@@ -25,62 +25,13 @@ watch(() => props.checked, (newValue) => {
</script>
<template>
<button :id="id" role="switch" class="vl-toggle-switch"
<button :id="id" role="switch"
class="vl-toggle-switch flex items-center w-[2.5em] h-[1.4em] rounded-[100px] bg-[var(--bg-container)] py-0.5 px-1 transition-[background-color] duration-300 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:aria-disabled="(props.disabled === true) ? 'true' : 'false'" :aria-label="label" :aria-labelledby="id"
:tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active"
:data-state="(active) ? 'checked' : 'unchecked'">
<div></div>
<div
class="transform-origin-center-left [will-change:tranform] relative left-0 w-[1em] h-[1em] bg-gray-100 rounded-full pointer-events-none transition-all duration-300">
</div>
</button>
</template>
<style scoped>
.vl-toggle-switch {
display: flex;
align-items: center;
font-size: inherit;
border: 0;
cursor: pointer;
width: 2.5em;
height: 1.4em;
background: var(--color-highlight);
border-radius: 100px;
padding: 0.125rem 0.25rem;
position: relative;
transition: background-color 0.3s ease;
}
.vl-toggle-switch[aria-disabled="true"] div {
opacity: 0.5;
cursor: not-allowed;
}
.vl-toggle-switch div {
transform-origin: center left;
will-change: transform;
position: relative;
left: 0;
width: 1em;
height: 1em;
background: #f7f7f7;
border-radius: 9999px;
pointer-events: none;
transition: all 0.3s;
}
.vl-toggle-switch[data-state="checked"] {
background: var(--color-accent);
}
.vl-toggle-switch[data-state="checked"] div {
transform-origin: center right;
transform: translateX(calc(2.5em - 1em - 0.5rem));
}
.vl-toggle-switch:active div {
width: 1.3em;
}
.vl-toggle-switch[data-state="checked"]:active div {
transform: translateX(calc(2.5em - 1.3em - 0.5rem));
}
</style>
+23 -19
View File
@@ -3,34 +3,38 @@ import type { DropdownItem } from '~/types/dropdown';
type Theme = 'light' | 'dark' | 'system';
const colorMode = useColorMode();
const { updateSettings, settings } = await useUserSettings();
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const selectTheme = (colorScheme: Theme) => {
updateSettings({ appearance: { colorScheme } });
};
const themeOptions: DropdownItem[] = [
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
{ value: 'system', label: 'System', icon: 'mynaui:desktop' },
{ id: 'light', label: 'Light', icon: 'mynaui:sun', onClick: () => selectTheme('light') },
{ id: 'dark', label: 'Dark', icon: 'mynaui:moon', onClick: () => selectTheme('dark') },
{ id: 'system', label: 'System', icon: 'mynaui:desktop', onClick: () => selectTheme('system') },
];
const currentOption = computed(
() => themeOptions.find((option) => option.value === colorMode.preference) || themeOptions[2],
() => themeOptions.find((option) => option.id === settings.value.appearance.colorScheme) || themeOptions[2],
);
const isOpen = ref(false);
const toggleDropdown = (e: MouseEvent) => {
e.stopPropagation();
const selectTheme = (item: DropdownItem) => {
colorMode.preference = item.value as Theme;
};
if (dropdownState.open) {
closeDropdown();
return;
}
openDropdown(e, () => themeOptions, { verticality: 'ascending', placement: 'right' });
}
</script>
<template>
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
placement="right" width="140px">
<template #trigger="{ toggle, isOpen }">
<button aria-label="Open theme switcher" @click="toggle" :class="[isOpen ? 'bg-[var(--color-highlight)] hover:text-[var(--color-subtle)] focus-visible:text-[var(--color-subtle)]' : 'bg-transparent text-[var(--color-muted)]',
'h-7 w-7 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors active:text-[var(--color-text)]'
]">
<Icon :name="currentOption!.icon!" class="text-4" />
</button>
</template>
</Dropdown>
<button aria-label="Open theme switcher" @click="toggleDropdown"
class="h-7 w-7 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)]">
<Icon :name="currentOption!.icon!" class="text-5 pointer-events-none" />
</button>
</template>
+8 -1
View File
@@ -4,7 +4,14 @@ import type { Entity } from "@triplit/client";
export const useAgents = async () => {
const triplit = useTriplitClient();
const { results: agents, unsubscribe } = await useQuery('agents', triplit, triplit.query('agents').Include('topics'));
const { results: agents, unsubscribe } = await useQuery(
'agents',
triplit,
triplit
.query('agents')
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
.Order('createdAt', 'ASC')
);
const createAgent = async (): Promise<Readonly<Entity<typeof schema, 'agents'>> | null> => {
const { user } = useAuth();
+88 -41
View File
@@ -1,8 +1,9 @@
import type schema from "#triplit/schema";
import type { Entity } from "@triplit/client";
import type { ModelMessage } from "ai";
import { nanoid } from "nanoid";
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
import { type Result, Ok, Err } from "~~/types/result";
import { type Result, Ok, Err, attempt } from "~~/types/result";
import { assert } from "~~/utils/assert";
export type MessageEntity = Entity<typeof schema, 'messages'> & {
@@ -22,6 +23,7 @@ export enum ChatErrorType {
NoAgent,
NoUser,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
GenerationFailed,
MarshallFailed,
NoProviderApiKey,
@@ -195,22 +197,27 @@ export const useChat = (agentId: string) => {
): Promise<Result<void, ChatErrorType>> => {
let providerApiKey: string | undefined = undefined;
if (provider.config.apiKey !== undefined) {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
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)
);
providerApiKey = await decrypt(
key,
base64ToUint8Array(provider.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
}
}
try {
$fetch('/api/chat/generate', {
await $fetch('/api/chat/generate', {
method: 'POST',
body: {
messages,
@@ -249,14 +256,17 @@ export const useChat = (agentId: string) => {
return Err(ChatErrorType.NoUser);
}
const messageId = nanoid();
const newMessage = await triplit.insert('messages', {
id: messageId,
userId: user.value.id,
topicId: topic.id,
createdAt: new Date().toISOString(),
content: message,
role: 'user',
}).catch(error => {
}).catch(async error => {
console.error('Failed to insert message:', error);
await triplit.delete('messages', messageId);
return Err(ChatErrorType.DatabaseOperationFailed);
}) as Message;
@@ -278,7 +288,14 @@ export const useChat = (agentId: string) => {
presence_penalty: 0,
};
return startGeneration(messages.data, args, topic, provider, model)
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);
}
return res;
});
};
/**
@@ -373,60 +390,90 @@ export const useChat = (agentId: string) => {
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
}
const autoRename = async (topicId: string, prompt: string) => {
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = await useUserSettings();
console.log(settings.value);
if (!settings.value.systemAssistants.rename.enabled) {
return false;
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return false;
return Err(AutoRenameError.NoModelSelected);
}
await triplit.update('topics', topicId, {
renaming: true
});
const model = await triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider'));
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 false;
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) {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
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)
);
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 $fetch(`/api/topic/auto-rename`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
topicId,
prompt,
providerApiKey,
}),
});
try {
const res = await $fetch(`/api/topic/auto-rename`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
topicId,
prompt,
providerApiKey,
}),
});
return true;
return Ok(res.renameId);
} catch (error) {
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
}
return {
sendMessage,
AutoRenameError,
autoRename,
regenerateMessage,
createTopic,
+77
View File
@@ -0,0 +1,77 @@
import type { DropdownItem } from "~/types/dropdown"
interface DropdownOptions {
placement?: 'right' | 'left' | 'center';
verticality?: 'ascending' | 'descending';
width?: string | number;
minWidth?: string;
maxWidth?: string;
}
interface DropdownState {
open: boolean;
x: number;
y: number;
itemsFactory: (() => DropdownItem[]) | null;
options: DropdownOptions | null;
}
const dropdownState = reactive<DropdownState>({
open: false,
x: 0,
y: 0,
itemsFactory: null,
options: null,
})
export const useDropdown = () => {
const openDropdown = (e: MouseEvent, itemsFactory: () => DropdownItem[], options?: DropdownOptions) => {
// TODO: take in placement logic and prefered verticality, and measure the space
// available in the direction of the prefered verticality, and if it doesnt fit
// in the direction of the placement, flip the placement
const rect = (e.target as HTMLElement).getBoundingClientRect()
dropdownState.x = rect.left
dropdownState.y = rect.bottom
if (options) {
switch (options.placement) {
case 'right':
dropdownState.x = rect.right
dropdownState.y = rect.bottom
break;
case 'left':
dropdownState.x = rect.left
dropdownState.y = rect.bottom
break;
case 'center':
dropdownState.x = rect.left + rect.width / 2
dropdownState.y = rect.bottom
break;
}
switch (options.verticality) {
case 'ascending':
dropdownState.y = rect.top;
break;
case 'descending':
dropdownState.y = rect.bottom;
break;
}
}
dropdownState.itemsFactory = itemsFactory
dropdownState.options = options ?? null
dropdownState.open = true
}
const closeDropdown = () => {
dropdownState.open = false
dropdownState.itemsFactory = null
dropdownState.options = null
}
return {
dropdownState,
openDropdown,
closeDropdown,
}
}
+2
View File
@@ -20,8 +20,10 @@ export const useModels = async () => {
.query('providers')
.Include('models');
const start = Date.now();
nuxtApp._modelsPromise = useQuery('providers', triplit, providersQuery).then((sub) => {
nuxtApp._modelsSubscription = sub;
console.log("fetching providers took", Date.now() - start);
return sub;
});
}
+1 -1
View File
@@ -3,7 +3,7 @@ export const useSidebar = () => {
const sidebarWidth = useState<number>('sidebar:width', () => {
return Number(
useCookie('sidebar:width', {
default: () => '226',
default: () => '250',
maxAge: 60 * 60 * 24 * 30,
}).value,
);
+55
View File
@@ -13,9 +13,64 @@ export const useTheme = () => {
maxAge: 60 * 60 * 24 * 365,
});
const colorScheme: { preference: Ref<'light' | 'dark' | 'system'>; value: Ref<'light' | 'dark'>; class: Ref<'light' | 'dark' | undefined> } = {
preference: useCookie('colorScheme', {
default: () => 'system',
maxAge: 60 * 60 * 24 * 365,
}),
value: computed(() => {
if (colorScheme.preference.value === 'system') {
if (import.meta.client) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
return 'dark';
}
return colorScheme.preference.value as 'light' | 'dark';
}),
class: ref<undefined | 'light' | 'dark'>(undefined)
}
let listeningToColorScheme = false;
const changeSystemColorScheme = (e: MediaQueryListEvent) => {
if (colorScheme.preference.value !== 'system') return;
colorScheme.class.value = e.matches ? 'dark' : 'light';
}
watch(colorScheme.preference, () => {
if (colorScheme.preference.value === 'system') {
if (import.meta.client) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (!listeningToColorScheme) {
listeningToColorScheme = true;
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', changeSystemColorScheme);
}
colorScheme.class.value = prefersDark ? 'dark' : 'light';
return;
}
if (listeningToColorScheme) {
listeningToColorScheme = false;
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', changeSystemColorScheme);
}
// fallback to dark on the server
colorScheme.class.value = 'dark';
return;
}
colorScheme.class.value = colorScheme.preference.value as 'light' | 'dark';
}, { immediate: true });
return {
accent,
neutral,
hinting,
colorScheme
};
};
+138 -4
View File
@@ -1,8 +1,142 @@
import { schema } from '#triplit/schema';
import { type Entity } from '@triplit/client';
import { computed, watch } from 'vue';
// TODO: most of this code is generated by gemini 3 flash with fixups,
// but its still bad code, so I'm going to clean it up later
export const useUserSettings = async () => {
const user = useAuth().user;
const { user, loggedIn } = useAuth();
const triplit = useTriplitClient();
const { accent, neutral, hinting, colorScheme } = useTheme();
const { results: settings, unsubscribe } = await useQuery('settings', triplit, triplit.query('settings').Where('userId', '=', user.value!.id));
const start = Date.now();
const remoteSettings = useState<Entity<typeof schema, 'settings'> | null>('user:settings', () => null);
const { results } = await useQuery('settings', triplit, triplit.query('settings'))
console.log("fetching settings took", Date.now() - start);
return { settings: computed(() => settings.value![0]!), unsubscribe };
}
watch(results, (val) => {
if (!val) {
remoteSettings.value = null;
return;
}
remoteSettings.value = val[0] as any;
}, { immediate: true, deep: true })
// Sync remote settings to local cookies
// This bridges the gap between the server state and the local CSS variable application
watch(remoteSettings, (newSettings) => {
if (!newSettings?.appearance) return;
const { appearance } = newSettings;
if (appearance.colorScheme && appearance.colorScheme !== colorScheme.preference.value) {
colorScheme.preference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
}
if (appearance.accent && appearance.accent !== accent.value) {
accent.value = appearance.accent;
}
if (appearance.neutral && appearance.neutral !== neutral.value) {
neutral.value = appearance.neutral;
}
if (appearance.hinting !== undefined && String(appearance.hinting) !== hinting.value) {
hinting.value = String(appearance.hinting);
}
}, { deep: true });
// Effective settings with defaults for backwards compatibility
const settings = computed(() => {
const remote = remoteSettings.value;
return {
appearance: {
colorScheme: remote?.appearance?.colorScheme ?? colorScheme.preference.value,
accent: remote?.appearance?.accent ?? accent.value,
neutral: remote?.appearance?.neutral ?? neutral.value,
hinting: remote?.appearance?.hinting ?? Number(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,
}
}
};
});
/**
* Update settings both locally and on the server
*/
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) colorScheme.preference.value = updates.appearance.colorScheme;
if (updates.appearance.accent) accent.value = updates.appearance.accent;
if (updates.appearance.neutral) neutral.value = updates.appearance.neutral;
if (updates.appearance.hinting !== undefined) hinting.value = String(updates.appearance.hinting);
}
if (!loggedIn.value || !user.value?.id) return;
const current = remoteSettings.value;
if (!current) {
await triplit.insert('settings', {
userId: user.value.id,
appearance: {
colorScheme: colorScheme.preference.value,
accent: accent.value,
neutral: neutral.value,
hinting: Number(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
};
}
});
};
return {
settings,
remoteSettings,
updateSettings,
};
};
+3 -3
View File
@@ -1,7 +1,7 @@
<template>
<div class="h-full w-full grid place-items-center">
<div
class="max-w-xs p-4 bg-[var(--color-neutral)] border border-solid border-[var(--color-highlight)] w-full rounded-lg">
class="max-w-xs p-4 bg-[var(--bg-surface)] border border-solid border-[var(--color-border)] w-full rounded-lg">
<slot />
</div>
</div>
@@ -9,14 +9,14 @@
<style>
input {
color: var(--color-text);
color: var(--text-primary);
padding-inline: calc(var(--spacing) * 4);
padding-block: calc(var(--spacing) * 2);
border-radius: calc(var(--spacing) * 1.5);
width: 100%;
background-color: var(--color-input);
background-color: var(--bg-container);
transition-property: color, border, background-color;
transition-duration: 300ms;
+11 -2
View File
@@ -2,17 +2,25 @@
import SettingsDialog from '~/components/Settings/Dialog.vue';
const { open: sidebarOpen, openSidebar } = useSidebar();
const { colorScheme } = useTheme();
useHead({
htmlAttrs: {
class: colorScheme.class
}
});
useKeyboardShortcuts();
</script>
<template>
<Sidenav />
<main
class="bg-[var(--color-neutral)] flex flex-col h-full w-full border border-solid border-[var(--color-highlight)] rounded-lg overflow-hidden">
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="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(--color-text)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors">
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
<Icon class="text-5" name="mynaui:panel-left-open" />
</button>
</div>
@@ -26,4 +34,5 @@ useKeyboardShortcuts();
</div>
</main>
<SettingsDialog />
<Dropdown />
</template>
+47 -23
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { ModelWithProvider } from '~/composables/useModels';
const triplit = useTriplitClient();
const route = useRoute();
const inputValue = ref('');
const pendingMessage = ref<Message | null>(null);
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string);
@@ -49,11 +52,38 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
const topic = await createTopic();
if (!topic) throw new Error('Failed to create topic');
autoRename(topic.id, message);
autoRename(topic.id, message).then(async res => {
if (res.ok === false) {
console.error('Failed to auto-rename:', res.error);
await triplit.update('topics', topic.id, {
renaming: false,
});
return;
}
});
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
return sendMessage(message, topic, [], agent.value!, model.provider, model);
return sendMessage(message, topic, [], agent.value!, model.provider, model).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();
});
}
}
});
};
onUnmounted(() => {
@@ -64,28 +94,22 @@ onUnmounted(() => {
<template>
<div class="h-full w-full">
<!-- chat pane -->
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
<div class="flex-grow w-full flex justify-center">
<div class="flex h-full max-w-4xl w-full flex-col gap-2"
:class="pendingMessage === null ? 'justify-end' : ''">
<div v-if="pendingMessage === null">
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
<p class="mb-28 text-[var(--color-muted)]">Select a topic to continue or create a new one</p>
</div>
<div class="opacity-70" v-else>
<Message :message="pendingMessage" />
</div>
<div
class="chat-scroll-container justify-center w-full h-full [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center">
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
<div class="w-full px-px flex flex-col flex-grow gap-2"
:class="pendingMessage === null ? 'justify-end pb-28' : 'pb-9'">
<div v-if="pendingMessage === null">
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
<p class="text-[var(--text-secondary)]">Select a topic to continue or create a new one</p>
</div>
<div v-else class="opacity-70">
<Message :message="pendingMessage" />
</div>
</div>
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
:providers="providers.filter(p => p.enabled)" @submit="handleSubmit"></ChatInput>
</div>
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:agent="agent" :providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" />
</div>
</div>
</div>
+3 -3
View File
@@ -49,13 +49,13 @@ onUnmounted(() => {
<Icon v-else name="mynaui:check-hexagon" class="text-16" />
</div>
<input @input="handleInput" placeholder="Agent Name..."
class="placeholder:text-[var(--color-highlight)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-highlight-high)] text-12 p-0"
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" />
</div>
<div class="flex flex-col gap-2 w-full h-full mb-14">
<label class="text-sm text-[var(--color-text-subtle)]">System Message</label>
<label class="text-sm text-[var(--text-secondary)]">System Message</label>
<textarea placeholder="You are a helpful assistant."
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-highlight)]"
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
</div>
</div>
+81 -68
View File
@@ -1,14 +1,17 @@
<script setup lang="ts">
import type { Message, MessageEntity } from '~/composables/useChat';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type schema from '#triplit/schema';
import type { Entity } from '@triplit/client';
import type { ModelWithProvider } from '~/composables/useModels';
const rootStart = Date.now();
const triplit = useTriplitClient();
const chatPane = ref<HTMLElement | null>(null);
const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref('');
const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels();
const { providers, allModels, unsubscribe: unsubscribeModels } = await useModels();
const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') {
@@ -59,39 +62,42 @@ const [
]);
const topic = computed(() => {
if (!rawMessages.value || !rawTopic.value || !rawTopic.value[0]) return null;
if (!rawMessages.value || !rawTopic.value?.[0]) return null;
// Build the messages tree manually for maximum performance
const messagesMap = new Map();
const partsByMessage = new Map<string, Entity<typeof schema, 'message_parts'>[]>();
// First pass: Create message objects with parts arrays
for (const msg of rawMessages.value) {
messagesMap.set(msg.id, {
...msg,
parts: [],
children: [],
generation: rawGenerations.value?.find(g => g.id === msg.generationId) ?? null
});
}
// Second pass: Attach parts to messages
// Group parts by message ID once
if (rawParts.value) {
for (const part of rawParts.value) {
const msg = messagesMap.get(part.messageId);
if (msg) {
// Filter empty parts here if needed, or just push
if (part.content !== '' || part.toolCall !== null) {
msg.parts.push(part);
}
if (!partsByMessage.has(part.messageId)) {
partsByMessage.set(part.messageId, []);
}
if (part.content !== '' || part.toolCall !== null) {
partsByMessage.get(part.messageId)!.push(part);
}
}
}
// Third pass: Build children relationships
const rootMessages = [];
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,
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);
messagesMap.get(msg.parentMessageId)!.children.push(msg);
} else {
rootMessages.push(msg);
}
@@ -104,26 +110,7 @@ const topic = computed(() => {
};
});
if (!topic.value) navigateTo(`/agent/${route.params.id}`);
const activeGeneration = computed(() => {
if (topic.value === null) return null;
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
});
const { scrollToBottom } = useAutoScroll(chatPane);
onMounted(() => {
scrollToBottom('instant');
});
const handleCancel = async () => {
await $fetch(`/api/chat/cancel/${activeGeneration.value!.id}`, {
method: 'POST',
});
};
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
const submitMessage = async (message: string, model: ModelWithProvider | null) => {
if (!model) {
console.error('No model selected');
return;
@@ -132,6 +119,14 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
if (!res.ok) {
console.error('Failed to send message:', res.error);
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
console.log("chat input", chatInput, message);
if (chatInput) {
inputValue.value = message;
nextTick(() => {
chatInput.focus();
});
}
return;
}
@@ -139,7 +134,7 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
};
const focusedMessageTree = computed(() => {
const tree: MessageEntity[] = [];
const tree: Readonly<MessageEntity>[] = [];
for (const message of topic.value?.messages || []) {
if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
if (message.focusedIndex === 0) {
@@ -156,7 +151,7 @@ const focusedMessageTree = computed(() => {
})
const handleRegenerate = async (message: Message) => {
if (!agent.value.defaultModelId) {
if (!agent.value!.defaultModelId) {
console.error('No model selected');
return;
}
@@ -172,7 +167,7 @@ const handleRegenerate = async (message: Message) => {
messageId = message.id;
}
const model = allModels.value.find(m => m.id === agent.value.defaultModelId);
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
if (!model) {
console.error('Model not found');
return;
@@ -254,37 +249,55 @@ const handleDelete = async (rootMessage: Message) => {
});
}
const activeGeneration = computed(() => {
if (topic.value === null) return null;
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
});
const { scrollToBottom } = useAutoScroll(chatPaneWrapper);
onMounted(() => {
scrollToBottom('instant');
});
const handleCancel = async () => {
await $fetch(`/api/chat/cancel/${activeGeneration.value?.id}`, {
method: 'POST',
});
};
console.log("full page render took", Date.now() - rootStart);
onUnmounted(() => {
unsubscribeTopic?.();
unsubscribeAgents?.();
unsubscribeMessages?.();
unsubscribeParts?.();
unsubscribeGenerations?.();
unsubscribeAgents?.();
unsubscribeModels?.();
});
</script>
<template>
<div class="h-full w-full">
<!-- chat pane -->
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
<div class="flex-grow w-full flex justify-center">
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9"
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
<Message v-for="message in topic.messages" @delete="handleDelete(message)" :key="message.id"
@regenerate="handleRegenerate(message)" :message="message" />
</div>
<!-- chat pane -->
<div ref="chatPaneWrapper"
class="chat-scroll-container justify-center w-full h-full [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center">
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
<div class="w-full px-px flex flex-col flex-grow gap-2 pb-9">
<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]"
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" />
</template>
</Suspense>
</div>
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:loading="activeGeneration !== null" :agent="agent"
:providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" @cancel="handleCancel">
</ChatInput>
</div>
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:loading="activeGeneration !== null" :agent="agent" :providers="providers?.filter(p => p.enabled)"
@submit="submitMessage" @cancel="handleCancel" />
</div>
</div>
</div>
</template>
</template>
+32 -27
View File
@@ -3,6 +3,8 @@ import type schema from '#triplit/schema';
import type { Entity } from '@triplit/client';
import { assert } from '~~/utils/assert';
const triplit = useTriplitClient();
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
const { providers, unsubscribe: unsubscribeModels, getFirstAvailableModel, allModels } = await useModels();
@@ -108,9 +110,34 @@ const handleChatSubmit = async (message: string, model: ModelWithProvider | null
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`);
autoRename(topic.id, message);
autoRename(topic.id, message).then(async res => {
if (res.ok === false) {
console.error('Failed to auto-rename:', res.error);
await triplit.update('topics', topic.id, {
renaming: false,
});
return sendMessage(message, topic, [], agent, model.provider, model);
return;
}
});
return sendMessage(message, topic, [], agent, model.provider, model).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();
});
}
}
});
};
onMounted(() => {
@@ -142,7 +169,9 @@ onUnmounted(() => {
<template>
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span class="cursor">&nbsp;</span></h1>
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span
class="animate-blink inline-block w-4 h-[0.9em] bg-current align-middle ml-0.5 select-none">&nbsp;</span>
</h1>
<div class="max-w-4xl h-full w-full">
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
@@ -150,27 +179,3 @@ onUnmounted(() => {
</div>
</div>
</template>
<style>
.cursor {
display: inline-block;
width: 1rem;
height: 0.9em;
background-color: currentColor;
animation: blink 1s step-end infinite;
vertical-align: middle;
margin-left: 0.125rem;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
</style>
+1
View File
@@ -23,6 +23,7 @@ export default defineNuxtPlugin({
assert('updateOptions' in triplit);
triplit.updateOptions({
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: session.value.token,
});
}
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineNuxtPlugin((nuxtApp) => {
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex);
.use(rehypeKatex, { output: 'mathml' });
return {
provide: {
+25
View File
@@ -0,0 +1,25 @@
import * as Comlink from 'comlink'
import type { ShikiWorker } from '~/workers/shiki'
let shikiWorker: ShikiWorker | null = null
export default defineNuxtPlugin(async (nuxtApp) => {
if (shikiWorker) {
return {
provide: {
shiki: shikiWorker,
}
}
}
const worker = new Worker(new URL('~/workers/shiki.ts', import.meta.url), { type: 'module' });
const shiki = Comlink.wrap<ShikiWorker>(worker);
await shiki.init();
return {
provide: {
shiki,
}
}
})
+24
View File
@@ -0,0 +1,24 @@
import { createShiki } from '~/utils/shiki'
import { type HighlighterCore } from 'shiki/core'
let shiki: HighlighterCore | null = null
export default defineNuxtPlugin(async (nuxtApp) => {
if (shiki) {
return {
provide: {
shiki,
}
}
}
// to prevent lazy loading, we provide the shiki highlighter
// via a plugin.
shiki = await createShiki()
return {
provide: {
shiki,
}
}
})
+3 -2
View File
@@ -1,8 +1,9 @@
export interface DropdownItem {
id?: string;
active?: boolean;
label: string;
icon?: string;
onClick?: () => void;
value?: string | number;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const providerBaseUrls = {
cerebras: 'https://api.cerebras.ai/v1',
google: 'https://generativelanguage.googleapis.com/v1beta',
longcat: 'https://api.longcat.chat/openai/v1',
cohere: 'https://api.cohere.ai/v1',
cohere: 'https://api.cohere.ai/v2',
};
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> };
+1
View File
@@ -9,6 +9,7 @@ export const hash = async (text: string) => {
return hashHex;
}
// simple djb2 hash
export const hashSync = (text: string) => {
let hash = 5381;
let i = 0;
+29 -13
View File
@@ -1,25 +1,34 @@
import {
LogoOpenAI,
LogoOpenrouter,
LogoOllama,
LogoCerebras,
LogoLongCat,
LogoGoogle,
LogoGrok,
LogoGemini,
LogoGemma,
LogoDeepMind,
LogoVertexAI,
LogoDeepCogito,
LogoQwen,
LogoOpenAI,
LogoArcee,
LogoAi2,
LogoOpenrouter,
LogoMoonshot,
LogoZAI,
LogoGLMV,
LogoChatGLM,
LogoNvidia,
LogoGemma,
LogoMistral,
LogoMeta,
LogoPerplexity,
LogoDeepCogito,
LogoDeepSeek,
LogoClaude,
LogoAnthropic,
LogoMorph,
LogoNousResearch,
LogoAi21,
LogoWenxin,
LogoBaiduCloud,
LogoMinimax,
LogoNova,
@@ -31,24 +40,18 @@ import {
LogoXiaomiMiMo,
LogoRelace,
LogoEssentialAI,
LogoClaude,
LogoKwaipilot,
LogoIBM,
LogoInternLM,
LogoLongCat,
LogoWenxin,
LogoHunyuan,
LogoInception,
LogoAya,
LogoCohere,
LogoAionLabs,
LogoMicrosoft,
LogoInflection,
LogoAya,
LogoDeepMind,
LogoVertexAI,
LogoGoogle
} from '#components';
import NousResearch from '~/components/Logo/NousResearch.vue';
import { markRaw } from 'vue';
interface ModelConfig {
Icon: any;
@@ -210,7 +213,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
keywords: [/^morph-/, /\/morph-/]
},
{
Icon: markRaw(NousResearch),
Icon: markRaw(LogoNousResearch),
keywords: [/deephermes/, /hermes/, /genstruct/, /minos/]
},
{
@@ -311,6 +314,19 @@ const MODEL_MAPPINGS: ModelConfig[] = [
}
];
export const providerIcons: Record<string, any | null> = {
ollama: markRaw(LogoOllama),
google: markRaw(LogoGoogle),
openai: markRaw(LogoOpenAI),
azure: null,
anthropic: null,
cohere: markRaw(LogoCohere),
huggingface: null,
openrouter: markRaw(LogoOpenrouter),
cerebras: markRaw(LogoCerebras),
longcat: markRaw(LogoLongCat),
};
export function getModelConfig(modelId: string) {
const cleanId = modelId.toLowerCase();
-2
View File
@@ -1,9 +1,7 @@
export const initSettings = async (userId: string) => {
const triplit = useTriplitClient();
console.log(userId);
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
console.log(settings);
if (!settings) {
console.log('no settings');
+39
View File
@@ -0,0 +1,39 @@
import { createHighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import vitesseDark from '@shikijs/themes/vitesse-dark'
import vitesseLight from '@shikijs/themes/vitesse-light'
import langBash from '@shikijs/langs/bash'
import langVue from '@shikijs/langs/vue'
import langTsx from '@shikijs/langs/tsx'
import langJavascript from '@shikijs/langs/javascript'
import langTypescript from '@shikijs/langs/typescript'
import langPython from '@shikijs/langs/python'
import langCss from '@shikijs/langs/css'
import langHtml from '@shikijs/langs/html'
import langMarkdown from '@shikijs/langs/markdown'
import langJSON from '@shikijs/langs/json'
export const createShiki = () => createHighlighterCore({
themes: [
vitesseDark,
vitesseLight,
],
langs: [
langTsx,
langJavascript,
langTypescript,
langBash,
langVue,
langPython,
langCss,
langHtml,
langMarkdown,
langJSON
],
langAlias: {
jsx: 'tsx',
},
engine: createOnigurumaEngine(import('shiki/wasm')),
});
+7
View File
@@ -0,0 +1,7 @@
export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => {
if (!a.releasedAt && !b.releasedAt) return 0;
if (!a.releasedAt) return 1;
if (!b.releasedAt) return -1;
return b.releasedAt.getTime() - a.releasedAt.getTime();
}
+25
View File
@@ -0,0 +1,25 @@
import { createShiki } from '~/utils/shiki'
import { type HighlighterCore } from 'shiki/core'
import { expose } from 'comlink'
let shiki: HighlighterCore | null = null
const api = {
async init() {
if (shiki) return
shiki = await createShiki()
},
async codeToHtml(code: string, options: { lang: string, themes: { light: string, dark: string } }) {
if (!shiki) throw new Error('shiki not initialized')
return shiki.codeToHtml(code, options)
},
async getLanguage(lang: string) {
if (!shiki) throw new Error('shiki not initialized')
const grammar = shiki.getLanguage(lang);
return { name: grammar.name }
}
}
expose(api)
export type ShikiWorker = typeof api
+656 -119
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,8 @@ export const auth = betterAuth({
}),
session: {
expiresIn: 60 * 60 * 24 * 30, // 1 month
updateAge: 60 * 60 * 24 * 1, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60,
+80 -24
View File
@@ -20,21 +20,63 @@ export default defineNuxtConfig({
'LLM, AI, Chat, Assistant, Agent, LLMs, OpenAI, GPT, GPT-3, GPT-4, Claude, ChatGPT, Whisper, Bard, Bing, Anthropic, DeepAI, Dolly, StableLM, Vicuna, Llama, Alpaca, ChatGLM, MOSS, MPT, Codex, Codex2, Codex 3, Codex 4, Falcon, Flan-T5, T5, Llama2, LLaMA, LLaMA2, StableLM, OpenAssistant, OpenChat',
},
],
link: [
{
rel: 'stylesheet',
href: 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css',
}
],
script: [
{
src: 'https://cdn.jsdelivr.net/npm/eruda',
innerHTML: `
(function() {
try {
const cookies = document.cookie.split('; ');
const colorSchemeCookie = cookies.find(row => row.startsWith('colorScheme='));
const colorScheme = colorSchemeCookie ? decodeURIComponent(colorSchemeCookie.split('=')[1]) : 'system';
if (colorScheme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(prefersDark ? 'dark' : 'light');
} else {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(colorScheme);
}
} catch (e) {}
})();
`,
tagPosition: 'head',
type: 'text/javascript',
},
{
innerHTML: `
eruda.init();
(function() {
const parent = document.createElement("div");
parent.setAttribute("style", "width:30px;height:30px;overflow:auto;position:absolute;top:-9999px;");
document.documentElement.appendChild(parent);
const child = document.createElement("div");
child.setAttribute("style", "width:100%;height:40px");
parent.appendChild(child);
const scrollbarWidth = parent.offsetWidth - child.offsetWidth;
if (scrollbarWidth > 0) {
document.documentElement.classList.add("has-obtrusive-scrollbars");
document.documentElement.style.setProperty('--scrollbar-width', scrollbarWidth + 'px');
parent.style.scrollbarWidth = 'thin';
const thinWidth = parent.offsetWidth - child.offsetWidth;
document.documentElement.style.setProperty('--thin-scrollbar-width', thinWidth + 'px');
}
document.documentElement.removeChild(parent);
})();
`,
}
tagPosition: 'bodyOpen',
type: 'text/javascript',
},
// {
// src: 'https://cdn.jsdelivr.net/npm/eruda',
// },
// {
// innerHTML: `
// eruda.init();
// `,
// }
]
},
},
@@ -45,9 +87,27 @@ export default defineNuxtConfig({
},
optimizeDeps: {
include: [
"shiki/themes/vitesse-dark.mjs", "shiki/themes/vitesse-light.mjs", "shiki/themes/min-light.mjs", "shiki/themes/min-dark.mjs", "shiki/langs/js.mjs", "shiki/langs/jsx.mjs", "shiki/langs/json.mjs", "shiki/langs/ts.mjs", "shiki/langs/tsx.mjs", "shiki/langs/vue.mjs", "shiki/langs/css.mjs", "shiki/langs/html.mjs", "shiki/langs/bash.mjs", "shiki/langs/md.mjs", "shiki/langs/mdc.mjs", "shiki/langs/yaml.mjs", "shiki/langs/py.mjs", "shiki/langs/typescript.mjs", "shiki/langs/javascript.mjs",
// shiki
'shiki/core',
'shiki/wasm',
'shiki/engine/javascript',
'shiki/engine/oniguruma',
'@shikijs/themes/vitesse-dark',
'@shikijs/themes/vitesse-light',
'@shikijs/langs/vue',
'@shikijs/langs/javascript',
'@shikijs/langs/typescript',
'@shikijs/langs/tsx',
'@shikijs/langs/python',
'@shikijs/langs/css',
'@shikijs/langs/html',
'@shikijs/langs/markdown',
'@shikijs/langs/json',
'@shikijs/langs/bash',
'@vue/devtools-core',
'@vue/devtools-kit',
'@sentry/nuxt',
'@nuxt/hints/runtime/hydration/component',
'vue-virtual-scroller',
'shiki',
@@ -59,9 +119,17 @@ export default defineNuxtConfig({
'remark-rehype',
'remark-math',
'rehype-katex',
'unist-util-visit',
'@triplit/client',
'@triplit/db'
'@triplit/db',
'comlink'
],
},
},
nitro: {
experimental: {
websocket: true,
}
},
@@ -70,13 +138,7 @@ export default defineNuxtConfig({
// viewTransition: true,
// },
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode', 'triplit-nuxt', 'nuxt-shiki', '@sentry/nuxt/module'],
shiki: {
bundledThemes: ['vitesse-dark', 'vitesse-light'],
bundledLangs: ['js', 'jsx', 'json', 'ts', 'tsx', 'vue', 'css', 'html', 'bash', 'md', 'mdc', 'yaml', 'py'],
defaultTheme: 'vitesse-dark',
},
modules: ['@vue-macros/nuxt', '@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', 'triplit-nuxt', '@sentry/nuxt/module', '@nuxt/fonts'],
triplit: {
schema_path: './triplit/schema.ts',
@@ -87,12 +149,6 @@ export default defineNuxtConfig({
autoConnect: false,
},
colorMode: {
preference: 'system',
fallback: 'dark',
storage: 'cookie',
},
devtools: {
enabled: true,
+76 -69
View File
@@ -1,71 +1,78 @@
{
"name": "veridian",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"triplit": "triplit dev -s sqlite",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@ai-sdk/cerebras": "^2.0.31",
"@ai-sdk/cohere": "^3.0.20",
"@ai-sdk/google": "^3.0.23",
"@ai-sdk/openai-compatible": "^2.0.28",
"@ai-sdk/vue": "^3.0.78",
"@daveyplate/better-auth-triplit": "^0.2.2",
"@iconify-json/mynaui": "^1.2.17",
"@nuxt/hints": "1.0.0-alpha.5",
"@nuxt/icon": "2.2.0",
"@nuxtjs/color-mode": "4.0.0",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@sentry/nuxt": "^10",
"@triplit/client": "^1.0.50",
"@types/big.js": "^6.2.2",
"ai": "^6.0.78",
"ai-sdk-ollama": "^3.5.0",
"better-auth": "^1.4.18",
"big.js": "^7.0.1",
"dotenv": "^17.2.4",
"glob": "^13.0.1",
"nanoid": "^5.1.6",
"nuxt": "4.2.2",
"nuxt-shiki": "0.3.2",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"triplit-nuxt": "0.3.1-prerelease.5",
"unified": "^11.0.5",
"vue": "^3.5.28",
"vue-router": "^4.6.4",
"vue-virtual-scroller": "^2.0.0-beta.8",
"zod": "^4.3.6"
},
"devDependencies": {
"@iconify-json/logos": "^1.2.10",
"@iconify-json/svg-spinners": "^1.2.4",
"@triplit/cli": "^1.0.61",
"@types/jsonwebtoken": "^9.0.10",
"@unocss/nuxt": "^66.6.0",
"jsonwebtoken": "^9.0.3",
"unocss": "^66.6.0"
},
"trustedDependencies": [
"@parcel/watcher",
"core-js",
"esbuild",
"unrs-resolver"
],
"patchedDependencies": {
"@triplit/db@1.1.10": "patches/@triplit%2Fdb@1.1.10.patch",
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch"
},
"overrides": {
"@vercel/nft": "^0.27.4"
}
"name": "veridian",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"triplit": "triplit dev -s sqlite",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"knip": "knip"
},
"dependencies": {
"@ai-sdk/cerebras": "^2.0.34",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@daveyplate/better-auth-triplit": "^0.2.2",
"@iconify-json/mynaui": "^1.2.17",
"@nuxt/fonts": "0.13.0",
"@nuxt/hints": "1.0.0-alpha.5",
"@nuxt/icon": "2.2.0",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@sentry/nuxt": "^10.39.0",
"@triplit/client": "^1.0.50",
"@triplit/server": "^1.1.8",
"@types/big.js": "^6.2.2",
"@vue-macros/nuxt": "^3.1.2",
"ai": "^6.0.89",
"ai-sdk-ollama": "^3.7.1",
"better-auth": "^1.4.18",
"big.js": "^7.0.1",
"comlink": "^4.4.2",
"glob": "^13.0.5",
"nanoid": "^5.1.6",
"nuxt": "4.2.2",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^3.22.0",
"triplit-nuxt": "0.3.1-prerelease.5",
"unified": "^11.0.5",
"vue": "^3.5.28",
"vue-router": "^4.6.4",
"vue-virtual-scroller": "^2.0.0-beta.8",
"zod": "^4.3.6"
},
"devDependencies": {
"@iconify-json/logos": "^1.2.10",
"@iconify-json/svg-spinners": "^1.2.4",
"@iconify-json/tabler": "^1.2.26",
"@triplit/cli": "^1.0.61",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@unocss/nuxt": "^66.6.0",
"jsonwebtoken": "^9.0.3",
"knip": "^5.84.1",
"typescript": "^5.9.3",
"unocss": "^66.6.0"
},
"trustedDependencies": [
"@parcel/watcher",
"core-js",
"esbuild",
"unrs-resolver"
],
"patchedDependencies": {
"@triplit/db@1.1.10": "patches/@triplit%2Fdb@1.1.10.patch",
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch"
},
"overrides": {
"@vercel/nft": "^0.27.4",
"vite": "8.0.0-beta.0"
}
}
+172 -2
View File
@@ -1,8 +1,77 @@
diff --git a/node_modules/@triplit/db/.bun-tag-16a302de81445385 b/.bun-tag-16a302de81445385
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@triplit/db/.bun-tag-38646fa5cb007988 b/.bun-tag-38646fa5cb007988
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@triplit/db/.bun-tag-c381351c2a0f3c94 b/.bun-tag-c381351c2a0f3c94
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/dist/db.js b/dist/db.js
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..9c4d3060894285218d2d1f961930b5dad52d7bc5 100644
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..0ce27b58838db11fd51fcf873a182d528d5823fb 100644
--- a/dist/db.js
+++ b/dist/db.js
@@ -307,9 +307,7 @@ export class DB {
@@ -17,7 +17,6 @@ import { Type } from './schema/data-types/index.js';
import { getCollectionPermissions } from './permissions.js';
import { QueryBuilder } from './query/query-builder.js';
import { validateSchema } from './schema/validation.js';
-import { tryPreloadingOptionalDeps } from './utils/optional-dep.js';
export class DB {
entityStore;
clock;
@@ -44,7 +43,7 @@ export class DB {
});
this.ivm =
options.ivm ??
- new IVM(
+ new IVM(
// @ts-expect-error - TODO: handle more generalized internal typings
this);
if (options.schema) {
@@ -73,9 +72,9 @@ export class DB {
getSchema() {
return this.schema;
}
- subscribe(query, onResults, onError,
- // TODO: will we need this?
- options = {}) {
+ subscribe(query, onResults, onError,
+ // TODO: will we need this?
+ options = {}) {
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
applyPermission: options.skipRules ? undefined : 'read',
});
@@ -136,11 +135,11 @@ export class DB {
}
const timestamp = await this.entityStore.metadataStore.getTimestampForEntity(this.kv, collection, entityId);
if (
- // TODO: determine if timestamp can ever be undefined
- // I think the only case could be if the entity was optimistically inserted
- // on the client but never synced to the server
- // assuming that we don't delete metadata when we delete entities
- timestamp &&
+ // TODO: determine if timestamp can ever be undefined
+ // I think the only case could be if the entity was optimistically inserted
+ // on the client but never synced to the server
+ // assuming that we don't delete metadata when we delete entities
+ timestamp &&
HybridLogicalClock.compare(timestamp, options.queryState.timestamp) < 0) {
if (!entitiesThatHaveNotChanged[collection]) {
entitiesThatHaveNotChanged[collection] = new Set();
@@ -192,9 +191,9 @@ export class DB {
onResults(relevantChanges, options.queryKey);
isInitialResponse = false;
};
- return this.ivm.subscribe(preparedQuery,
- // @ts-expect-error - Ignoring because method is deprecated
- callback, options.errorCallback);
+ return this.ivm.subscribe(preparedQuery,
+ // @ts-expect-error - Ignoring because method is deprecated
+ callback, options.errorCallback);
}
async fetch(query, options) {
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
@@ -307,9 +306,7 @@ export class DB {
// TODO call the listeners in the entity store
// Trigger subscription updates
await this.ivm.bufferChanges(changes);
@@ -13,3 +82,104 @@ index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..9c4d3060894285218d2d1f961930b5da
return output;
}
async applyChanges(changes, options) {
@@ -609,7 +606,6 @@ export async function createDB(options) {
if (options.kv) {
savedSchema = await DB.getSchemaFromStorage(options.kv);
}
- await tryPreloadingOptionalDeps();
db = new DB({ ...options, schema: savedSchema });
let schemaChange = undefined;
// A schema is provided, attempt to apply it
diff --git a/dist/index.js b/dist/index.js
index 5155e43bb742663d121d2c5a50275398d8b79b36..9fbd6beac969f25612d74a57b845b973ea914637 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,4 +1,3 @@
-import './polyfills.js';
export * from './codec.js';
export * from './db.js';
export * from './db-transaction.js';
diff --git a/dist/polyfills.d.ts b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.d.ts
deleted file mode 100644
index 825f04fd5dba36fd87c308e87e15b2c3c5be8712..0000000000000000000000000000000000000000
diff --git a/dist/polyfills.js b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.js
deleted file mode 100644
index 264a2151aaf63c967a2f8820454d2692f4522c99..0000000000000000000000000000000000000000
diff --git a/dist/polyfills.js.map b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.js.map
deleted file mode 100644
index 23685d5015568d42f4273c442fff1dacac3151d4..0000000000000000000000000000000000000000
diff --git a/dist/schema/data-types/type.js b/dist/schema/data-types/type.js
index 2e1281c5577b48ffe609a08da933b5f342a485fa..e88487852f6c49185144a9057ffb11d93a3b6cc8 100644
--- a/dist/schema/data-types/type.js
+++ b/dist/schema/data-types/type.js
@@ -3,7 +3,6 @@ import { DBDeserializationError, DBSerializationError, JSONDeserializationError,
import { prefixOperations, SET_OP_PREFIX, SUPPORTED_OPERATIONS, } from './operations.js';
import { DEFAULTABLE_TYPE_KEYS_SET, PRIMITIVE_TYPE_KEYS_SET } from './index.js';
import { hasNoValue, isDefaultFunction } from '../../utils/value.js';
-import { getOptionalDep } from '../../utils/optional-dep.js';
/**
* Returns an empty object for the given type
* If the type is not a record, it returns undefined
@@ -151,8 +150,8 @@ export function encode(type, input) {
throw new DBSerializationError(`set<${type.items.type}>`, input);
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to encode value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to encode value');
}
export function validateEncoded(type, encoded, options) {
switch (type.type) {
@@ -219,8 +218,8 @@ export function validateEncoded(type, encoded, options) {
};
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to validate value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to validate value');
}
export function decode(type, encoded) {
switch (type.type) {
@@ -274,8 +273,8 @@ export function decode(type, encoded) {
throw new DBDeserializationError(`set<${type.items.type}>`, encoded);
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to decode value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to decode value');
}
// FOR SET KEYS
// Must encode to string
@@ -407,8 +406,8 @@ export function supportedOperations(type) {
if (type.type === 'string')
return SUPPORTED_OPERATIONS.string;
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to get supported operations');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to get supported operations');
}
/**
* Checks if the type has a default value that can be configured
@@ -440,9 +439,6 @@ function calcDefaultValue(config) {
else if (func === 'uuidv4') {
return crypto.randomUUID();
}
- else if (func === 'uuidv7') {
- return getOptionalDep('uuidv7').uuidv7();
- }
else if (func === 'now') {
return new Date().toISOString();
}
diff --git a/dist/utils/optional-dep.d.ts b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.d.ts
deleted file mode 100644
index aa726389af2582fdb764fe313691423ffde89c4c..0000000000000000000000000000000000000000
diff --git a/dist/utils/optional-dep.js b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js
deleted file mode 100644
index 850317d787608b58f4083aa91e282c3598e32fa3..0000000000000000000000000000000000000000
diff --git a/dist/utils/optional-dep.js.map b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js.map
deleted file mode 100644
index d39488cfa8b65ac4d7e8b9909c82b7af8cc838e1..0000000000000000000000000000000000000000
@@ -8,20 +8,19 @@ export default defineEventHandler(async (event) => {
const success = cancelPendingGeneration(generationId!);
if (!success) {
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
if (generation !== null && generation.status === 'pending') {
await httpClient.update('generations', generationId!, {
status: 'cancelled',
});
return;
}
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
if (generation !== null && generation.status === 'pending') {
await httpClient.update('generations', generationId!, {
status: 'cancelled',
});
}
if (!success) {
throw createError({
statusCode: 400,
message: 'Generation not found or already completed',
});
}
return;
return 'ok';
});
+33 -9
View File
@@ -9,7 +9,8 @@ import { httpClient } from '~~/server/lib/triplit';
import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations';
import type { schema } from '~~/triplit/schema';
import { spawn } from 'child_process';
import { getGateway, type ModelGateway } from '~~/server/utils/ai-provider';
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
import { assert } from '~~/utils/assert';
export const messagesSchema = z.array(modelMessageSchema);
@@ -73,7 +74,26 @@ export default defineEventHandler(async (event) => {
});
}
const { gateway, streamTransformer: transformer } = await getGateway(provider, model, providerApiKey);
const providerDetails = await getProviderDetails(provider, providerApiKey, model);
if (!providerDetails.ok) {
switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: {
throw createError({
statusCode: 400,
message: `${provider.type} provider requires an API key`,
});
}
case GatewayFetchError.NoProviderBaseUrl: {
throw createError({
statusCode: 400,
message: 'Invalid provider URL',
});
}
}
}
const { gateway } = providerDetails.data;
assert(gateway !== null, 'Invalid gateway');
const generationId = nanoid();
const message = await httpClient.insert('messages', {
@@ -98,14 +118,20 @@ export default defineEventHandler(async (event) => {
let logMessage: ((message: string) => void) | undefined;
if (process.env.GENERATION_DEBUG) {
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
logMessage = (message: string) => {
logFile!.write(message + '\n');
};
if (process.env.LOG_DIR) {
await fs.mkdir(process.env.LOG_DIR!, { recursive: true });
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
logMessage = (message: string) => {
logFile!.write(message + '\n');
};
} else {
console.warn('Generation debug logging is enabled but GENERATION_DEBUG is not set');
}
}
event.waitUntil(
generateResponse(message, { gateway, model, parameters: args }, generationId, userId, topicId, messages, transformer, logMessage, logFile),
generateResponse(message, { gateway: gateway.gateway, model, parameters: args }, generationId, userId, topicId, messages, gateway.streamTransformer, logMessage, logFile),
);
return {
@@ -755,8 +781,6 @@ async function generateResponse(
status: 'failed',
error,
});
throw new Error(error);
} break;
case 'finish': {
let tps;
+271 -99
View File
@@ -1,7 +1,13 @@
import { type Entity } from '@triplit/client';
import Big from 'big.js';
import * as z from 'zod';
import { providerBaseUrls, SupportedModalities } from '~/types/model';
import { SupportedModalities } from '~/types/model';
import { Providers } from '~/types/model';
import { httpClient } from '~~/server/lib/triplit';
import { GatewayFetchError, getProviderDetails } from '~~/server/utils/ai-provider';
import { getModelsDevData } from '~~/server/utils/models-dev';
import { schema } from '~~/triplit/schema';
import { Err, Ok, type Result } from '~~/types/result';
export default defineEventHandler(async (event) => {
await protectRoute(event);
@@ -32,109 +38,130 @@ export default defineEventHandler(async (event) => {
});
}
let baseUrl;
let fetchUrl;
let headers;
switch (provider.type) {
case 'cohere':
case 'cerebras':
if (!result.data.providerApiKey) {
console.log("apiKey", result.data.providerApiKey);
const [providerModelsRes, modelsDevRes] = await Promise.all([
fetchProviderModels(provider, result.data.providerApiKey),
getModelsDevData(),
]);
if (!providerModelsRes.ok) {
switch (providerModelsRes.error) {
case ProviderFetchError.NoProviderApiKey: {
throw createError({
statusCode: 400,
message: `${provider.type} provider requires an API key`,
});
}
case 'google':
case 'openrouter':
baseUrl = !!provider.config.apiProxyUrl ? provider.config.apiProxyUrl : providerBaseUrls[provider.type];
baseUrl = baseUrl.replace(/\/$/, '');
if (baseUrl === '') {
case ProviderFetchError.NoProviderBaseUrl: {
throw createError({
statusCode: 400,
message: 'Invalid provider URL',
});
}
}
}
fetchUrl = `${baseUrl}/models`;
break;
case 'ollama':
if (!provider.config.apiProxyUrl) {
throw createError({
statusCode: 400,
message: 'Ollama provider requires an API proxy URL',
});
console.log("providerModelsRes.data", providerModelsRes.data);
const normalizedModels = await normalizeResponse(
providerModelsRes.data.data,
provider.type,
providerModelsRes.data.baseURL,
modelsDevRes
);
return {
models: normalizedModels,
};
});
enum ProviderFetchError {
NoProviderApiKey = 0,
NoProviderBaseUrl,
}
const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => {
const providerDetails = await getProviderDetails(provider, providerApiKey);
if (!providerDetails.ok) {
switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: {
// throw createError({
// statusCode: 400,
// message: `${provider.type} provider requires an API key`,
// });
return Err(ProviderFetchError.NoProviderApiKey);
}
baseUrl = provider.config.apiProxyUrl;
baseUrl = baseUrl.replace(/\/$/, '');
case GatewayFetchError.NoProviderBaseUrl: {
// throw createError({
// statusCode: 400,
// message: 'Invalid provider URL',
// });
return Err(ProviderFetchError.NoProviderBaseUrl);
}
}
}
fetchUrl = `${baseUrl}/api/tags`;
break;
const { endpoint: { baseURL, modelsEndpoint, headers } } = providerDetails.data;
switch (provider.type) {
case 'longcat':
// longcat doesn't have a model list endpoint so we just hardcode them here,
// sry. I talked to Meituan and this is what they said:
// 后续如果我们新增了这样的接口会及时同步您。
// en (approx): If we add an interface like this in the future, we will promptly update you
return {
models: [
{
id: "LongCat-Flash-Chat",
name: "LongCat Flash Chat",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['tools'],
contextWindow: 256_000,
return Ok({
data: {
models: [
{
id: "LongCat-Flash-Chat",
name: "LongCat Flash Chat",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['tools'],
contextWindow: 256_000,
}
},
{
id: "LongCat-Flash-Thinking",
name: "LongCat Flash Thinking",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['reasoning', 'tools'],
contextWindow: 256_000,
}
},
{
id: "LongCat-Flash-Thinking-2601",
name: "LongCat Flash Thinking (2601)",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['reasoning', 'tools'],
contextWindow: 256_000,
}
},
{
id: "LongCat-Flash-Lite",
name: "LongCat Flash Lite",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['tools'],
contextWindow: 320_000,
}
}
},
{
id: "LongCat-Flash-Thinking",
name: "LongCat Flash Thinking",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['reasoning', 'tools'],
contextWindow: 256_000,
}
},
{
id: "LongCat-Flash-Thinking-2601",
name: "LongCat Flash Thinking (2601)",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['reasoning', 'tools'],
contextWindow: 256_000,
}
},
{
id: "LongCat-Flash-Lite",
name: "LongCat Flash Lite",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['tools'],
contextWindow: 320_000,
}
}
]
};
}
if (provider.type === 'google') {
headers = {
'x-goog-api-key': `${result.data.providerApiKey}`
}
} else {
headers = {
'Authorization': `Bearer ${result.data.providerApiKey}`
}
],
},
baseURL
});
}
let res;
let data;
try {
res = await fetch(fetchUrl, {
res = await fetch(`${baseURL}${modelsEndpoint}`, {
method: 'GET',
headers
});
@@ -154,16 +181,89 @@ export default defineEventHandler(async (event) => {
});
}
return {
models: await normalizeResponse(data, provider.type, baseUrl)
};
});
return Ok({ data, baseURL });
}
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string) => {
function mergeSets(setA: Set<string>, setB: Set<string>): string[] {
return Array.from(new Set([...setA, ...setB]));
}
const getModelData = (modelId: string, providerId: string, modelsDevData: any) => {
const modelData = modelsDevData[providerId]?.models[modelId];
console.log("modelData", modelData);
if (modelData === undefined) return {
cost: {},
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: [],
}
};
const capabilities = new Set<string>();
if (modelData.reasoning) {
capabilities.add('reasoning');
}
if (modelData.tool_call) {
capabilities.add('tools');
}
let inputModalities = modelData.modalities.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
let outputModalities = modelData.modalities.output.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
let supportedParameters = [];
if (modelData.temperature) {
supportedParameters.push('temperature');
}
let contextWindow = modelData.limit?.context || null;
let cost: Record<string, string> = {};
for (const key in modelData.cost) {
switch (key) {
case 'input': {
cost.prompt = modelData.cost[key].toString();
} break;
case 'output': {
cost.completion = modelData.cost[key].toString();
} break;
}
}
let releasedAt = (new Date(modelData.release_date)).getTime();
return {
name: modelData.name,
attributes: {
inputModalities,
outputModalities,
capabilities: modelData.capabilities || [],
contextWindow,
supported_parameters: modelData.supportedParameters,
},
cost,
releasedAt,
}
}
const formatBig = (bigValue: Big) => {
let str = bigValue.toString();
if (!str.includes('.')) return str + '.00';
if (str.split('.')[1]!.length === 1) return str + '0';
return str;
};
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any) => {
switch (provider) {
case 'cerebras': {
console.log(response);
return response.data.map((model: any) => ({ id: model.id, releasedAt: model.created }));
console.log("response.data", response.data);
for (const model of response.data) {
console.log(model.created);
}
return response.data.map((model: any) => ({ ...getModelData(model.id, provider, modelsDevData), id: model.id }));
}
case 'openrouter': {
const models = [];
@@ -182,18 +282,69 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
}
}
const pricing = {} as Record<string, string>;
for (const key of Object.keys(model.pricing)) {
// normalize to /1M tokens since models.dev is already in /1M tokens
switch (key) {
case 'prompt':
case 'completion':
case 'request':
case 'image':
case 'audio':
case 'discount':
pricing[key] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'image_tokens':
pricing['imageTokens'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'image_output':
pricing['imageOutput'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'audio_output':
pricing['audioOutput'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'input_audio_cache':
pricing['inputAudioCache'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'web_search':
pricing['webSearch'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'internal_reasoning':
pricing['internalReasoning'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'input_cache_read':
pricing['inputCacheRead'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
case 'input_cache_write':
pricing['inputCacheWrite'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
break;
}
}
// TODO: do I really need to pull in data from models.dev for OR models?
// const modelData = getModelData(model.id, provider, modelsDevData);
models.push({
id: model.id as string,
name: model.name as string,
pricing: model.pricing,
cost: pricing,
attributes: {
// inputModalities: mergeSets(
// new Set(modelData.inputModalities || []),
// new Set(model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
// ),
// outputModalities: mergeSets(
// new Set(modelData.outputModalities || []),
// new Set(model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
// ),
inputModalities: model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
capabilities: Array.from(capabilities),
contextWindow: model.context_length,
supported_parameters: model.supported_parameters,
},
created: model.created,
releasedAt: model.created * 1000,
});
}
@@ -255,12 +406,26 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
console.error(e);
}
let normalizedId = model.name;
normalizedId = normalizedId.replace(/:cloud$/, '');
normalizedId = normalizedId.replace(/-cloud$/, '');
normalizedId = normalizedId.replace(/:latest$/, '');
const modelData = getModelData(normalizedId, 'ollama-cloud', modelsDevData);
models.set(model.name, {
...modelData,
id: model.name,
name: model.name,
attributes: {
inputModalities: Array.from(inputModalities),
capabilities: Array.from(capabilities),
...modelData.attributes,
inputModalities: mergeSets(
inputModalities,
new Set(modelData.attributes?.inputModalities || [])
),
capabilities: mergeSets(
capabilities,
new Set(modelData.attributes?.capabilities || [])
),
contextWindow,
}
});
@@ -272,12 +437,10 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return Array.from(models.values());
}
case 'google': {
console.log(response);
return response.models.map((model: any) => ({ id: model.name.replace('models/', ''), name: model.displayName }));
return response.models.map((model: any) => ({ ...getModelData(model.name.replace('models/', ''), provider, modelsDevData), id: model.name.replace('models/', ''), name: model.displayName }));
}
case 'longcat': {
console.log(response);
return response.models.map((model: any) => ({ id: model.name, name: model.name }));
return response.models;
}
case 'cohere': {
const models = [];
@@ -299,13 +462,22 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
}
}
const modelData = getModelData(model.name, provider, modelsDevData);
models.push({
...modelData,
id: model.name,
name: model.name,
attributes: {
contextWindow: model.context_length,
inputModalities: Array.from(inputModalities),
capabilities: Array.from(capabilities),
...modelData.attributes,
inputModalities: mergeSets(
inputModalities,
new Set(modelData.attributes?.inputModalities || [])
),
capabilities: mergeSets(
capabilities,
new Set(modelData.attributes?.capabilities || [])
),
contextWindow: model.context_length || modelData.attributes?.contextWindow,
}
});
}
@@ -1,3 +1,4 @@
import { httpClient } from '~~/server/lib/triplit';
import { cancelPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert';
@@ -7,7 +8,15 @@ export default defineEventHandler(async (event) => {
const { renameId } = event.context.params!;
assert(renameId);
if (cancelPendingRename(renameId)) {
const [success, pendingRename] = cancelPendingRename(renameId);
if (success) {
const topic = await httpClient.fetchOne(httpClient.query('topics').Where('id', '=', pendingRename!.topicId));
if (topic !== null && topic.renaming) {
await httpClient.update('topics', topic.id, {
renaming: false,
});
}
return {
success: true,
};
+28 -4
View File
@@ -4,8 +4,9 @@ import { renamePrompt } from '~~/prompts';
import { schema } from '~~/triplit/schema';
import { type Entity } from '@triplit/client';
import { generateText } from 'ai';
import { getGateway, type ModelGateway } from '~~/server/utils/ai-provider';
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
import { addPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert';
export default defineEventHandler(async (event) => {
await protectRoute(event);
@@ -38,10 +39,31 @@ export default defineEventHandler(async (event) => {
message: 'Invalid model',
});
}
assert(model.provider !== null, 'Invalid model provider');
const { gateway, textTransformer } = await getGateway(model.provider!, model, providerApiKey);
const [renameId, abortController] = addPendingRename();
event.waitUntil(autoRename(topicId, abortController, { gateway, model }, textTransformer, prompt));
const providerDetails = await getProviderDetails(model.provider, providerApiKey, model);
if (!providerDetails.ok) {
switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: {
throw createError({
statusCode: 400,
message: `${model.provider.type} provider requires an API key`,
});
}
case GatewayFetchError.NoProviderBaseUrl: {
throw createError({
statusCode: 400,
message: 'Invalid provider URL',
});
}
}
}
const { gateway } = providerDetails.data;
assert(gateway !== null, 'Invalid gateway');
const [renameId, pendingRename] = addPendingRename(topicId);
event.waitUntil(autoRename(topicId, renameId, pendingRename.abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt));
return {
success: true,
@@ -51,6 +73,7 @@ export default defineEventHandler(async (event) => {
const autoRename = async (
topicId: string,
renameId: string,
abortController: AbortController,
model: {
gateway: ModelGateway,
@@ -86,6 +109,7 @@ const autoRename = async (
} catch (error) {
console.error('Failed to auto-rename:', error);
} finally {
completeRename(renameId);
await httpClient.update('topics', topicId, {
renaming: false
});
+1 -1
View File
@@ -3,6 +3,6 @@ import { schema } from '#triplit/schema';
export const httpClient = new HttpClient({
schema,
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: process.env.TRIPLIT_SERVICE_TOKEN,
});
+10
View File
@@ -0,0 +1,10 @@
export default defineEventHandler((event) => {
const start = Date.now(); //
// Hook to run when the response is about to be sent
event.node.res.on('finish', () => {
const end = Date.now();
const duration = end - start;
console.log(`Request to ${event.req.url} took ${duration}ms to render.`); //
});
});
+83 -39
View File
@@ -9,6 +9,7 @@ import { schema } from "~~/triplit/schema";
import { type StreamTextTransform } from "ai";
import { transformCerebrasReasoningStream } from "./cerebras";
import { providerBaseUrls } from "~/types/model";
import { type Result, Err, Ok } from "~~/types/result";
// import { createLongcatTransformer } from "./longcat";
export type ModelGateway = OpenRouterProvider | OllamaProvider | CerebrasProvider | GoogleGenerativeAIProvider | OpenAICompatibleProvider | CohereProvider;
@@ -19,90 +20,133 @@ export interface Gateway {
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined;
}
export async function getGateway(provider: Entity<typeof schema, 'providers'>, model: Entity<typeof schema, 'models'>, providerApiKey?: string): Promise<Gateway> {
let gateway: ModelGateway;
let streamTransformer = undefined;
let textTransformer = undefined;
export interface Provider {
gateway: Gateway | null;
endpoint: {
baseURL: string;
modelsEndpoint: string | null;
headers: Record<string, string>;
}
}
export enum GatewayFetchError {
NoProviderApiKey = 0,
NoProviderBaseUrl,
}
export async function getProviderDetails(provider: Entity<typeof schema, 'providers'>, providerApiKey?: string, model?: Entity<typeof schema, 'models'>): Promise<Result<Provider, GatewayFetchError>> {
let gateway: Gateway = {} as Gateway;
let baseURL = undefined;
let modelsEndpoint = undefined;
let headers: Record<string, string> = {};
if (provider.config.apiProxyUrl && provider.config.apiProxyUrl.trim() !== '') {
baseURL = provider.config.apiProxyUrl;
} else {
baseURL = providerBaseUrls[provider.type];
}
baseURL = baseURL.replace(/\/$/, '');
switch (provider.type) {
case 'openrouter': {
if (providerApiKey === undefined) {
throw createError({
statusCode: 400,
message: 'OpenRouter provider requires an API key',
});
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway = createOpenRouter({
gateway.gateway = createOpenRouter({
apiKey: providerApiKey,
headers: {
'HTTP-Referer': 'https://localhost:3000',
'X-Title': 'Veridian',
},
});
break;
}
headers['Authorization'] = `Bearer ${providerApiKey}`
modelsEndpoint = `/models`;
} break;
case 'ollama': {
if (baseURL === undefined) {
throw createError({
statusCode: 400,
message: 'Ollama provider requires an API proxy URL',
});
return Err(GatewayFetchError.NoProviderBaseUrl);
}
const innerGateway = createOllama({
apiKey: providerApiKey,
baseURL,
})
if (providerApiKey !== undefined) {
headers['Authorization'] = `Bearer ${providerApiKey}`
}
modelsEndpoint = `/api/tags`;
gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider;
break;
}
if (model !== undefined) {
const innerGateway = createOllama({
apiKey: providerApiKey,
baseURL,
})
gateway.gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider;
}
} break;
case 'cerebras': {
gateway = createCerebras({
if (providerApiKey === undefined) {
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway.gateway = createCerebras({
apiKey: providerApiKey,
baseURL,
})
streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>;
textTransformer = (text: string) => {
gateway.streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>;
gateway.textTransformer = (text: string) => {
return text.split('</think>').at(-1)!.trim()
};
break;
}
headers['Authorization'] = `Bearer ${providerApiKey}`
modelsEndpoint = `/models`;
} break;
case 'google': {
gateway = createGoogleGenerativeAI({
if (providerApiKey === undefined) {
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway.gateway = createGoogleGenerativeAI({
apiKey: providerApiKey,
baseURL,
})
break;
}
});
headers['x-goog-api-key'] = `${providerApiKey}`
modelsEndpoint = `/models`;
} break;
case 'longcat': {
gateway = createOpenAICompatible({
if (providerApiKey === undefined) {
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway.gateway = createOpenAICompatible({
name: 'LongCat',
apiKey: providerApiKey,
baseURL: baseURL ?? providerBaseUrls[provider.type],
includeUsage: true,
})
headers['Authorization'] = `Bearer ${providerApiKey}`
modelsEndpoint = null;
// streamTransformer = createLongcatTransformer() as StreamTextTransform<{}>;
} break;
case 'cohere': {
gateway = createCohere({
if (providerApiKey === undefined) {
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway.gateway = createCohere({
apiKey: providerApiKey,
baseURL,
})
}
});
headers['Authorization'] = `Bearer ${providerApiKey}`
modelsEndpoint = `/models`;
} break;
}
return {
return Ok({
gateway,
streamTransformer,
textTransformer,
};
endpoint: {
baseURL,
modelsEndpoint,
headers,
}
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { ToolSet, type TextStreamPart, type ToolCallPart } from 'ai';
import { type ToolSet, type TextStreamPart, type ToolCallPart } from 'ai';
export function createLongcatTransformer<TOOLS extends ToolSet>(): (options: {
tools: TOOLS;
+51
View File
@@ -0,0 +1,51 @@
interface ModelsDevCache {
data: any;
etag: string | null;
lastFetched: number;
}
let modelsDevCache: ModelsDevCache = {
data: null,
etag: null,
lastFetched: 0,
};
const MODELS_DEV_URL = 'https://models.dev/api.json';
const CACHE_TTL = 60 * 1000; // 60 seconds
export async function getModelsDevData(): Promise<any> {
const now = Date.now();
if (modelsDevCache.data &&
(now - modelsDevCache.lastFetched < CACHE_TTL)) {
return modelsDevCache.data;
}
try {
const headers: HeadersInit = {};
if (modelsDevCache.etag) {
headers['If-None-Match'] = modelsDevCache.etag;
}
const response = await fetch(MODELS_DEV_URL, { headers });
if (response.status === 304) {
modelsDevCache.lastFetched = now;
return modelsDevCache.data;
}
const newData = await response.json();
const newEtag = response.headers.get('ETag') || null;
modelsDevCache = {
data: newData,
etag: newEtag,
lastFetched: now,
};
return newData;
} catch (error) {
console.error('Error fetching models.dev:', error);
return modelsDevCache.data || {};
}
}
+18 -11
View File
@@ -1,13 +1,19 @@
const pendingRenames: Map<string, AbortController> = new Map();
interface PendingRename {
topicId: string;
abortController: AbortController;
}
export const cancelPendingRename = (renameId: string): boolean => {
const controller = pendingRenames.get(renameId);
if (controller) {
controller.abort();
const pendingRenames: Map<string, PendingRename> = new Map();
export const cancelPendingRename = (renameId: string): [boolean, PendingRename?] => {
const pendingRename = pendingRenames.get(renameId);
if (pendingRename?.abortController) {
pendingRename.abortController.abort();
pendingRenames.delete(renameId);
return true;
return [true, pendingRename];
}
return false;
return [false, undefined];
};
export const completeRename = (renameId: string) => {
@@ -17,10 +23,11 @@ export const completeRename = (renameId: string) => {
}
};
export const addPendingRename = (): [string, AbortController] => {
export const addPendingRename = (topicId: string): [string, PendingRename] => {
const id = crypto.randomUUID();
const controller = new AbortController();
const abortController = new AbortController();
const pendingRename = { topicId, abortController };
pendingRenames.set(id, controller);
return [id, controller];
pendingRenames.set(id, pendingRename);
return [id, pendingRename];
};
+7
View File
@@ -17,6 +17,13 @@ export const schema = S.Collections({
systemAssistants: S.Record({
rename: systemAssistantRecord
}),
appearance: S.Record({
colorScheme: S.String({ nullable: true, default: 'system' }),
accent: S.String({ nullable: true }),
neutral: S.String({ nullable: true }),
hinting: S.Number({ nullable: true }),
fontSize: S.String({ nullable: true }),
}, { nullable: true }),
}),
permissions: {
authenticated: {
+8 -2
View File
@@ -2,7 +2,13 @@
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"compilerOptions": {
"noFallthroughCasesInSwitch": true
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"noFallthroughCasesInSwitch": true,
},
"references": [
{
@@ -18,4 +24,4 @@
"path": "./.nuxt/tsconfig.node.json"
}
]
}
}
+12 -1
View File
@@ -18,4 +18,15 @@ export const Err = <E>(error: E): Err<E> => ({
error,
});
export type Result<T, E> = Ok<T> | Err<E>;
export type Result<T, E> = Ok<T> | Err<E>;
export async function attempt<T, E = Error>(
promise: Promise<T>,
): Promise<Result<T, E>> {
try {
const result = await promise;
return Ok(result);
} catch (error) {
return Err(error as E);
}
}
-7
View File
@@ -8,12 +8,5 @@ export default defineConfig({
],
presets: [
presetMini(),
presetWebFonts({
provider: 'fontsource',
fonts: {
sans: 'Geist',
mono: 'JetBrains Mono',
},
}),
],
});