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
+2 -2
View File
@@ -4,10 +4,10 @@
I'm not entire sure why, I think its a race condition, but I'm not sure. 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. 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 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 it _without_ moving your mouse off of the sidenav, the agent button/dropdown
trigger will slowly crawl to the right (likely related to #2). trigger will slowly crawl to the right (likely related to #2).
+20 -2
View File
@@ -1,5 +1,23 @@
# Todo # Todo
- [X] Standardize on one icon set rather than 4 (lmao) - [x] Standardize on one icon set rather than 4 (lmao)
- [ ] Make dropdowns a singleton component - [-] Make dropdowns a singleton component (work in progress)
- [ ] Make the sidebar better :kekdoggo:. It's annoying to manage across routes - [ ] 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"> <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/reset.css';
import '~/assets/css/base.css'; import '~/assets/css/base.css';
@@ -9,7 +11,14 @@ if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
useHead({ useHead({
htmlAttrs: { 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', '--color-accent-hover',
`var(--accent-${accent.value}-hover, var(--accent-violet-hover))`, `var(--accent-${accent.value}-hover, var(--accent-violet-hover))`,
); );
document.documentElement.style.setProperty( // document.documentElement.style.setProperty(
'--neutral-dark', // '--color-neutral',
`var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`, // `var(--neutral-${neutral.value}, var(--neutral-zinc))`,
); // );
document.documentElement.style.setProperty( document.documentElement.style.setProperty('--neutral-100', `var(--palette-${neutral.value}-100)`);
'--neutral-light', document.documentElement.style.setProperty('--neutral-200', `var(--palette-${neutral.value}-200)`);
`var(--neutral-${neutral.value}-light, var(--neutral-zinc-light))`, document.documentElement.style.setProperty('--neutral-300', `var(--palette-${neutral.value}-300)`);
);
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`); document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`);
}); });
} }
useHead({
bodyAttrs: {
class: 'font-sans'
}
})
</script> </script>
<template> <template>
+344 -21
View File
@@ -1,9 +1,7 @@
:root { :root {
--font-sans: system-ui, sans-serif;
--sidebar-width: 400px;
--spacing: 0.25rem; --spacing: 0.25rem;
--color-accent-text: #000; --color-accent-text: #232625;
--reasoning-accent: #a62bcb; --reasoning-accent: #a62bcb;
@@ -53,18 +51,68 @@
--accent-magenta: #ec4899; --accent-magenta: #ec4899;
--accent-magenta-hover: #db2777; --accent-magenta-hover: #db2777;
/* Neutral Base Colors - different gray variations */ --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));
/* zinc - deep gray-blue */ --bg-container: color-mix(in srgb, var(--neutral-300), var(--color-accent) var(--accent-hinting));
--neutral-zinc-dark: #171619;
--neutral-zinc-light: #fbfafd;
/* charcoal - deep black-gray */
--neutral-charcoal-dark: #0a0a0a;
--neutral-charcoal-light: #fefdff;
} }
:root.dark { :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-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb, --color-highlight-low: color-mix(in srgb,
var(--base-hinting) rgba(255, 255, 255, 0.05), 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-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-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-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)); --color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
} }
:root.light { :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-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb, --color-highlight-low: color-mix(in srgb,
var(--base-hinting) rgba(0, 0, 0, 0.06), 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-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-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-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)); --color-reasoning: color-mix(in srgb, var(--base-hinting) #99979c, var(--accent-hinting) var(--color-accent));
} } */
html, html,
body { 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; padding: 0;
margin: 0; margin: 0;
background-color: var(--color-base); background-color: var(--bg-base);
color: var(--color-text); color: var(--text-primary);
}
.font-mono {
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
} }
html.dark { html.dark {
@@ -155,3 +208,273 @@ button.accent:hover {
.capitalize { .capitalize {
text-transform: 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"> <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 { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Entity } from '@triplit/client'; import type { Entity } from '@triplit/client';
import { schema } from '#triplit/schema'; import { schema } from '#triplit/schema';
const { allModels } = await useModels(); const { allModels } = await useModels();
const inputHeight: Ref<string> = ref('auto');
const inputRef = ref<HTMLTextAreaElement | null>(null); const inputRef = ref<HTMLTextAreaElement | null>(null);
let tempInput = ''; let tempInput = '';
const inputValue = ref(''); const inputValue = defineModel<string>({ required: false, default: '' });
const triplit = useTriplitClient(); const triplit = useTriplitClient();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,10 +23,8 @@ const props = defineProps<{
providers?: ProviderWithModels[]; providers?: ProviderWithModels[];
}>(); }>();
// Model selection state
const selectedModel = ref<ModelWithProvider | null>(null); const selectedModel = ref<ModelWithProvider | null>(null);
// Initialize model selection based on agent's defaultModelId or first available
const initializeModel = () => { const initializeModel = () => {
if (selectedModel.value) return; if (selectedModel.value) return;
if (!props.providers || !props.agent) return; if (!props.providers || !props.agent) return;
@@ -42,12 +41,10 @@ const initializeModel = () => {
// Fall back to first available model // Fall back to first available model
if (allModels.value.length > 0) { if (allModels.value.length > 0) {
selectedModel.value = allModels.value[0]!; selectedModel.value = allModels.value[0]!;
// Update agent's default model
updateAgentDefaultModel(selectedModel.value.id); updateAgentDefaultModel(selectedModel.value.id);
} }
}; };
// Update agent's default model in Triplit
const updateAgentDefaultModel = async (modelId: string) => { const updateAgentDefaultModel = async (modelId: string) => {
if (!props.agent) return; if (!props.agent) return;
try { try {
@@ -103,8 +100,6 @@ const handleKeyDown = async (event: KeyboardEvent) => {
inputValue.value = inputValue.value =
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition); inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
await nextTick();
handleInput();
return; return;
} }
event.preventDefault(); event.preventDefault();
@@ -112,11 +107,12 @@ const handleKeyDown = async (event: KeyboardEvent) => {
} }
}; };
const handleInput = () => { watch(inputValue, async () => {
const textarea = inputRef.value; const textarea = inputRef.value;
if (!textarea) return; if (!textarea) return;
textarea.style.height = 'auto'; inputHeight.value = 'auto';
await nextTick();
const lineHeight = 24; const lineHeight = 24;
const maxLines = 10; const maxLines = 10;
@@ -125,11 +121,11 @@ const handleInput = () => {
const newHeight = textarea.scrollHeight; const newHeight = textarea.scrollHeight;
if (newHeight > maxHeight) { if (newHeight > maxHeight) {
textarea.style.height = `${maxHeight}px`; inputHeight.value = `${maxHeight}px`;
} else { } else {
textarea.style.height = `${newHeight}px`; inputHeight.value = `${newHeight}px`;
} }
}; }, { immediate: true });
let hasCommandKey = false; let hasCommandKey = false;
if (import.meta.server) { if (import.meta.server) {
@@ -145,40 +141,34 @@ onBeforeMount(() => {
onMounted(() => { onMounted(() => {
inputValue.value = tempInput; inputValue.value = tempInput;
nextTick(() => {
handleInput();
});
}); });
</script> </script>
<template> <template>
<div :class="['w-full flex max-h-full', $attrs.class]"> <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)] <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-highlight)] focus-within:border-[var(--color-highlight-high)]"> border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
<!-- Text Input -->
<div class="flex-1 min-w-0 max-h-full"> <div class="flex-1 min-w-0 max-h-full">
<!-- Grammarly literally breaks everything, go fuck yourself --> <!-- 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" <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.`" :placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
@keydown="handleKeyDown" @input="handleInput" @keydown="handleKeyDown" :style="{ height: inputHeight }"
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"> class="[scrollbar-width:none] w-full bg-transparent resize-none text-[0.95em] placeholder:text-[var(--text-tertiary)]"></textarea>
</textarea>
</div> </div>
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2"> <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"> <div class="flex-1">
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers"> <ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers" />
</ModelSelector>
</div> </div>
<!-- Send/Stop Button -->
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading" <button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
:class="[ :class="[
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent', '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 inputValue.trim() && !loading
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]' ? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
: 'text-[var(--color-highlight-high)]', : 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]', loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]',
]"> ]">
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" /> <Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
<Icon v-else name="mynaui:send-solid" class="text-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>
+17 -83
View File
@@ -1,97 +1,31 @@
<script setup lang="ts"> <script setup>
import type { DropdownItem } from '~/types/dropdown'; import { useDropdown } from '~/composables/useDropdown';
const { dropdownState, closeDropdown } = useDropdown();
const menuRef = ref(null);
interface Props { const currentItems = computed(() => dropdownState.itemsFactory?.());
items?: DropdownItem[];
placement?: 'right' | 'left' | 'center';
verticality?: 'asscending' | 'descending';
width?: string;
}
const props = withDefaults(defineProps<Props>(), { useClickOutside(menuRef, closeDropdown);
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;
});
</script> </script>
<template> <template>
<div ref="triggerRef" :class="$attrs.class"> <Teleport to="body">
<slot name="trigger" :toggle="toggle" :is-open="isOpen"></slot>
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" <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" 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-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"> leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen" :class="[ <div v-if="dropdownState.open" ref="menuRef"
'absolute z-50 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-1.5 flex flex-col gap-1', :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'})` }"
placementClasses 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">
]" :style="{ width: width !== 'auto' ? width : undefined }"> <!-- Render your items based on activeMenu.content -->
<template v-for="(item, index) in items" :key="index"> <button v-for="item in currentItems" @click="item.onClick(); closeDropdown()" :disabled="item.disabled"
<div v-if="item.divider" class="h-px bg-[var(--color-highlight)] my-1" /> 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)]"
<button @click="select(item); item.onClick && item.onClick()" :disabled="item.disabled" :class="{ 'text-red-600 hover:bg-red-600/20': item.danger, 'text-primary bg-[var(--color-hover)]': item.active }">
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" /> <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> <span class="text-sm whitespace-nowrap text-ellipsis max-w-full overflow-hidden">{{ item.label
<slot name="item-after" :item="item"></slot> }}</span>
</button> </button>
</template>
<slot name="content" :toggle="toggle"></slot>
</div> </div>
</Transition> </Transition>
</div> </Teleport>
</template> </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> <template>
<div class="inline-flex items-center justify-center" <div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']"> :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});` : '']" :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"> viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title> <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"> <script setup lang="ts">
import type { RootContent } from 'hast'; import { h, Text, computed } from 'vue';
import MarkdownShikiHighlight from './ShikiHighlight.vue'; import MarkdownShikiHighlight from './ShikiHighlight.vue';
const props = defineProps<{ const props = defineProps<{
@@ -10,292 +10,42 @@ const props = defineProps<{
const { $remark } = useNuxtApp(); const { $remark } = useNuxtApp();
function splitMarkdown(markdown: string): string[] { const ast = computed(() => {
const paragraphs: string[] = []; const mdast = $remark.parse(props.content);
let currentParagraph = ""; return $remark.runSync(mdast);
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;
}); });
let activeIdx = 0; const renderNode = (node: any, index: number): any => {
let partIdx = [0]; if (node.type === 'text' || node.type === 'raw') return h(Text, node.value);
if (import.meta.client && hastParts.value && hastParts.value.length > 0 && !props.finished) { if (node.type === 'element') {
const initialParts = splitMarkdown(props.content); if (node.tagName === 'code') {
const isBlock = node.position?.start.line !== node.position?.end.line;
activeIdx = Math.max(0, initialParts.length - 1); if (isBlock && node.children?.[0]?.type === 'text') {
return h(MarkdownShikiHighlight, {
let currentOffset = 0; key: `code-${index}`,
for (let i = 0; i < initialParts.length; i++) { code: node.children[0].value,
partIdx[i] = currentOffset; language: node.properties?.className?.[0]?.replace('language-', '') || 'text'
});
const tempAst = await partseAst(initialParts[i]!);
currentOffset += tempAst.children.length;
} }
}
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]; const children = node.children?.map((child: any, i: number) => renderNode(child, i)) || [];
if (currentString !== undefined) {
const latestHast = await partseAst(currentString);
const stableBase = hastParts.value!.slice(0, partIdx[activeIdx]); return h(
hastParts.value = stableBase.concat(latestHast.children); node.tagName,
{ ...node.properties, key: `${node.tagName}-${index}` },
children
);
} }
return null;
};
defineRender(() => {
const children = ast.value?.children?.flatMap(renderNode) || [];
return h('div', { class: 'prose-wrapper' }, [
h('article', { class: 'markdown-body' }, children)
]);
}) })
</script> </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> <script lang="ts" setup>
import { type Grammar } from 'shiki';
import { hashSync } from '~/utils/hash'; 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 start = Date.now();
const codeBlockRef = ref<HTMLDivElement | null>(null);
const codeHeight: Ref<string | number> = ref('auto'); const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
const copied = ref(false); const copied = ref(false);
const collapsed = ref(false); const collapsed = ref(false);
const { data: parsed } = useAsyncData(`shiki-${renderId}`, async () => { const { $shiki } = useNuxtApp();
const { html, displayLang } = await parseCode();
return { html, displayLang }; const { data: parsed, clear } = await useAsyncData(`shiki-${renderId}`,
}); () => parseCode(props.code, props.language.toLowerCase()),
{
watch: [() => props.code],
dedupe: 'defer'
}
);
const lineNumberWidth = computed(() => { const lineNumberWidth = computed(() => {
if (!parsed.value?.html) return 1; if (!parsed.value?.html) return 1;
// Count newlines in the generated HTML or the source code // 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; return props.code.split('\n').length.toString().length;
}); });
watch(() => props.code, async () => { async function parseCode(code: string, lang: string) {
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();
let displayLang = lang; let displayLang = lang;
try { try {
const shikiLang = shiki.getLanguage(lang); let shikiLang = await $shiki.getLanguage(lang);
displayLang = shikiLang.name;
displayLang = (shikiLang as unknown as Grammar).name;
} catch { } catch {
lang = 'text'; lang = 'text';
} }
const html = shiki.codeToHtml(props.code.trim(), { let html = await $shiki.codeToHtml(code.trim(), {
lang, lang,
themes: { dark: 'vitesse-dark', light: 'vitesse-light' }, themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
}); });
@@ -57,81 +57,47 @@ function copyCode() {
}, 2000); }, 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(() => { onUnmounted(() => {
if (copyTimeout) clearTimeout(copyTimeout); if (copyTimeout) clearTimeout(copyTimeout);
clear();
}); });
console.log("shiki codeblock rendered in", Date.now() - start);
</script> </script>
<template> <template>
<div class="flex flex-col my-2 rounded-xl overflow-hidden"> <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"> <div class="capitalize">
{{ parsed?.displayLang }} {{ parsed?.displayLang }}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="copyCode()" <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 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" /> <Icon v-else name="mynaui:check" class="text-4 text-emerald-500" />
</button> </button>
<button @click="collapseCode()" <button @click="collapsed = !collapsed"
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)]"> 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" <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> </button>
</div> </div>
</div> </div>
<div ref="codeBlockRef" <div class="grid transition-all duration-350 ease-in-out"
class="font-mono overflow-hidden code-container transition-height duration-300 ease-in-out" :class="collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'"
:style="`--line-number-width: ${lineNumberWidth}ch; ${codeStyle}`" :id="`code-${renderId}`" :style="`--line-number-width: ${lineNumberWidth}ch;`" :id="`code-${renderId}`">
v-html="parsed?.html"> <div class="overflow-hidden code-container" v-html="parsed?.html"></div>
</div> </div>
</div> </div>
</template> </template>
<style> <style>
.code-container>pre { .code-container>pre {
overflow-x: auto; overflow: auto hidden;
min-height: 0;
scrollbar-width: thin; scrollbar-width: thin;
padding: 1rem; padding: 1rem;
line-height: 0; line-height: 0;
+1 -1
View File
@@ -19,6 +19,6 @@ if (props.error !== null && props.error !== undefined) {
<template> <template>
<span class="text-sm text-[var(--color-error)]">Generation failed</span> <span class="text-sm text-[var(--color-error)]">Generation failed</span>
<div class="text-sm"> <div class="text-sm">
<ShikiHighlight :code="code ?? 'An unknown error occurred'" lang="json" /> <ShikiHighlight :code="code ?? 'An unknown error occurred'" language="json" />
</div> </div>
</template> </template>
+7 -17
View File
@@ -51,13 +51,14 @@ const toggleReasoning = async () => {
</script> </script>
<template> <template>
<div class="text-[--text-tertiary]">
<button @click="toggleReasoning" :class="[ <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)]', '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' part.finished ? '' : 'cursor-default'
]"> ]">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div <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">
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" /> <Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
</div> </div>
Deep Thinking Deep Thinking
@@ -65,21 +66,10 @@ const toggleReasoning = async () => {
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" /> <Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
</button> </button>
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll" <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]"> :class="['reasoning-contaizner max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]">
<div class="p-2">
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" /> <MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
</div> </div>
</div>
</div>
</template> </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 html = ref('');
const lineNumberWidth = ref(1); const lineNumberWidth = ref(1);
@@ -87,15 +87,15 @@ const input: ComputedRef<string> = computed(() => {
watch( watch(
input, input,
(newCode) => { async (newCode) => {
let lang = 'json'; let lang = 'json';
try { try {
shiki.getLanguage(lang); $shiki.getLanguage(lang);
} catch (e) { } catch (e) {
lang = 'text'; lang = 'text';
} }
html.value = shiki.codeToHtml(newCode, { html.value = await $shiki.codeToHtml(newCode, {
lang, lang,
themes: { themes: {
dark: 'vitesse-dark', dark: 'vitesse-dark',
@@ -109,8 +109,8 @@ watch(
</script> </script>
<template> <template>
<div class="w-full border rounded-lg border-[var(--color-highlight)] flex flex-row h-80 overflow-hidden"> <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-highlight)] p-1 relative shrink-0"> <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="[ <button @click="activeTab = 'input'" :class="[
'function-call-tab-selector', 'function-call-tab-selector',
activeTab === 'input' ? 'text-orange-6' : '' activeTab === 'input' ? 'text-orange-6' : ''
@@ -207,7 +207,7 @@ watch(
} }
.function-call-tab-selector:hover:enabled { .function-call-tab-selector:hover:enabled {
background-color: var(--color-highlight); background-color: var(--color-hover);
} }
.function-call-tab-selector:disabled { .function-call-tab-selector:disabled {
+6 -6
View File
@@ -17,17 +17,17 @@ const toggleDebugToolCall = () => {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <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 justify-between w-full">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div <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 --> <!-- 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" <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" <Icon v-else-if="toolCall.status === 'failed'" name="mynaui:x-solid"
class="w-3 h-3 text-[#ff3b3b]" /> 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> </div>
{{ toolCall.toolName }} {{ toolCall.toolName }}
</div> </div>
@@ -35,8 +35,8 @@ const toggleDebugToolCall = () => {
<div <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)]"> 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" <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)]"> 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(--color-subtle)]" /> <Icon name="mynaui:search" class="w-3 h-3 text-[var(--text-secondary)]" />
</button> </button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -25,7 +25,7 @@ defineProps<{
<span class="flex items-center gap-1" <span class="flex items-center gap-1"
v-if="message.generation?.status === 'pending' && (message.parts || []).length === 0"> v-if="message.generation?.status === 'pending' && (message.parts || []).length === 0">
<Icon name="svg-spinners:pulse-2" class="text-4" /> <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... Preparing generating...
</span> </span>
</span> </span>
@@ -34,7 +34,7 @@ defineProps<{
<Error :error="message.generation.error" /> <Error :error="message.generation.error" />
</div> </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'"> v-if="message.generation && message.generation.status !== 'pending'">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<ModelIcon :size="12" :model-id="message.generation.modelId" /> <ModelIcon :size="12" :model-id="message.generation.modelId" />
+1 -1
View File
@@ -8,6 +8,6 @@ defineProps<{
</script> </script>
<template> <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" /> :content="message.content!" :id="message.id" />
</template> </template>
+6 -6
View File
@@ -119,7 +119,7 @@ const messageCount = computed(() => {
<button :inert="focusedIndex === 0" @click="focusedIndex = focusedIndex! - 1"> <button :inert="focusedIndex === 0" @click="focusedIndex = focusedIndex! - 1">
<Icon name="mynaui:chevron-left" :class="['w-4 h-4', focusedIndex === 0 ? 'opacity-0' : '']" /> <Icon name="mynaui:chevron-left" :class="['w-4 h-4', focusedIndex === 0 ? 'opacity-0' : '']" />
</button> </button>
<span class="text-xs text-[var(--color-muted)]"> <span class="text-xs text-[var(--text-secondary)]">
{{ focusedIndex + 1 }} / {{ messageCount }} {{ focusedIndex + 1 }} / {{ messageCount }}
</span> </span>
<button :inert="focusedIndex + 1 === messageCount" @click="focusedIndex = focusedIndex + 1"> <button :inert="focusedIndex + 1 === messageCount" @click="focusedIndex = focusedIndex + 1">
@@ -129,18 +129,18 @@ const messageCount = computed(() => {
</div> </div>
</div> </div>
<div :class="activeMessage.generation?.status === 'pending' ? 'opacity-0!' : ''" <div v-show="activeMessage.generation?.status !== 'pending'"
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)]"> 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" <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" /> <Icon name="mynaui:refresh" class="text-4.5" />
</button> </button>
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }" <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" /> <Icon :name="copied ? 'mynaui:check' : 'mynaui:copy'" class="text-4.5" />
</button> </button>
<button @click="deleteMessage" <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" /> <Icon name="mynaui:trash" class="text-5" />
</button> </button>
</div> </div>
+2 -2
View File
@@ -17,8 +17,8 @@ const config = computed(() => getModelConfig(props.modelId));
v-bind="config.props" /> v-bind="config.props" />
<!-- Fallback if no logo matches --> <!-- Fallback if no logo matches -->
<div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }" <div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }"
class="bg-[var(--color-highlight)] rounded-md flex items-center justify-center"> class="bg-[var(--bg-container)] rounded-md flex items-center justify-center">
<Icon name="tabler:brain" class="text-5 text-[var(--color-muted)]" /> <Icon name="tabler:brain" class="text-5 text-[var(--text-secondary)]" />
</div> </div>
</div> </div>
</template> </template>
+15 -29
View File
@@ -19,8 +19,6 @@ const props = withDefaults(defineProps<{
showEdit: false, showEdit: false,
}); });
const { Big } = await import('big.js');
const triplit = useTriplitClient(); const triplit = useTriplitClient();
const formatContextWindow = (window: number | null | undefined): string => { 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); 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(() => { const showCost = computed(() => {
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request); return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
}); });
@@ -68,16 +57,16 @@ const deleteModel = async () => {
<ModelIcon :class="{ <ModelIcon :class="{
'rounded-lg overflow-hidden': size === 'large', 'rounded-lg overflow-hidden': size === 'large',
'rounded-md overflow-hidden': size === 'medium' || size === 'small', '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'" /> :size="size === 'small' ? '20' : size === 'medium' ? '26' : '32'" />
<div class="flex flex-col gap-1 min-w-0 flex-1"> <div class="flex flex-col gap-1 min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0"> <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 }} {{ model.name }}
</span> </span>
<span v-if="showExternalId" <span v-if="showExternalId" :title="model.externalId"
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)] whitespace-nowrap truncate max-w-[240px]"> 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 }} {{ model.externalId }}
</span> </span>
<div v-if="showEdit" class="flex items-center gap-2"> <div v-if="showEdit" class="flex items-center gap-2">
@@ -88,25 +77,22 @@ const deleteModel = async () => {
</div> </div>
</div> </div>
<div v-if="details" class="flex items-center gap-1.5 flex-wrap"> <div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]">
<span v-if="showReleaseDate && model.releasedAt" <span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap">
class="text-xs text-[var(--color-muted)] whitespace-nowrap">
Released on {{ model.releasedAt.toISOString().split('T')[0] }} Released on {{ model.releasedAt.toISOString().split('T')[0] }}
</span> </span>
<template v-if="showCost"> <template v-if="showCost">
<span v-if="model.cost.prompt" <span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center">
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center"> <span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span> ${{ model.cost.prompt }}/M input
${{ formatBig(Big(model.cost.prompt).mul(1000000)) }}/M input
</span> </span>
<span v-if="model.cost.completion" <span v-if="model.cost.completion" class="text-xs whitespace-nowrap flex items-center">
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center"> <span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span> ${{ model.cost.completion }}/M output
${{ formatBig(Big(model.cost.completion).mul(1000000)) }}/M output
</span> </span>
<span v-if="model.cost.request && model.cost.request !== '0'" <span v-if="model.cost.request && model.cost.request !== '0'"
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center"> class="text-xs whitespace-nowrap flex items-center">
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span> <span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
{{ model.cost.request }}/request {{ model.cost.request }}/request
</span> </span>
</template> </template>
@@ -131,7 +117,7 @@ const deleteModel = async () => {
</div> </div>
<span v-if="model.attributes.contextWindow" <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) }} {{ formatContextWindow(model.attributes.contextWindow) }}
</span> </span>
+120 -44
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Entity } from '@triplit/client'; import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema'; import type schema from '#triplit/schema';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort';
const { setPage } = useSettings(); const { setPage } = useSettings();
const { allModels } = await useModels(); const { allModels } = await useModels();
@@ -20,6 +21,9 @@ const searchInputRef = ref<HTMLInputElement | null>(null);
const dropdownDirection = ref<'up' | 'down'>('up'); const dropdownDirection = ref<'up' | 'down'>('up');
const dropdownMaxHeight = ref<number | undefined>(undefined); const dropdownMaxHeight = ref<number | undefined>(undefined);
const navigatingWithKeyboard = ref(false);
const focusedOptionId = ref<string | null>(null);
const findContainer = (startingElement: HTMLElement): HTMLElement | null => { const findContainer = (startingElement: HTMLElement): HTMLElement | null => {
let container: HTMLElement | null = startingElement; let container: HTMLElement | null = startingElement;
while (container) { while (container) {
@@ -58,9 +62,41 @@ const filteredProviders = computed(() => {
return filterProvidersWithModel(props.providers, searchQuery.value).filter(p => p.enabled); 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'>) => { const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
selectedModel.value = { ...model, provider }; selectedModel.value = { ...model, provider };
isOpen.value = false; closeDropdown();
}; };
watch(() => props.providers, () => { watch(() => props.providers, () => {
@@ -89,34 +125,69 @@ watch(isOpen, (open) => {
nextTick(() => { nextTick(() => {
searchInputRef.value?.focus(); searchInputRef.value?.focus();
}); });
} else {
focusedOptionId.value = null;
searchQuery.value = '';
} }
}); });
const handleInputKeypress = (event: KeyboardEvent) => { const closeDropdown = () => {
if (event.key === 'Enter') {
isOpen.value = false; isOpen.value = false;
} navigatingWithKeyboard.value = false;
} };
const handleKeyDown = (event: KeyboardEvent) => { const handleSearchKeyDown = (event: KeyboardEvent) => {
// TODO: in the settings page, pressing escape closes BOTH if (flatOptions.value.length === 0) return;
// the settings modal and the model selector dropdown
if (event.key === 'Escape') { switch (event.key) {
isOpen.value = false; 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, () => { useClickOutside(dropdownRef, closeDropdown);
isOpen.value = false;
});
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
});
</script> </script>
<template> <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="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
:class="[ :class="[
isOpen isOpen
? 'bg-[var(--color-highlight)] text-[var(--color-text)]' ? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: 'text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)]', : '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" <ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
:model-id="selectedModel.externalId" size="22" /> :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" 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-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"> 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="[ <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', dropdownDirection === 'up' ? 'bottom-full mb-2 origin-bottom' : 'top-full mt-2 origin-top',
]" :style="{ maxHeight: dropdownMaxHeight ? `${dropdownMaxHeight}px` : '460px', height: 'auto' }" ]" :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"> 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>
<div class="relative"> <div class="relative">
<Icon name="mynaui:search" <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" <input ref="searchInputRef" v-model="searchQuery" autocomplete="off" name="search"
@keypress="handleInputKeypress" type="text" placeholder="Search models..." @keydown="handleSearchKeyDown" 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" /> 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> </div>
<div class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none max-w-full overflow-hidden"> <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" <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 No models found
</div> </div>
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2"> <div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
<div <div v-if="provider.models.filter(m => m.enabled).length > 0"
class="px-4 py-1.5 text-[13px] font-medium text-[var(--color-muted)] capitalize tracking-wider flex justify-between"> class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] capitalize tracking-wider flex justify-between">
{{ provider.name }} {{ provider.name }}
<button @click="isOpen = true; setPage('providers', provider.id)" <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)]"> 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-3.5" /> <Icon name="mynaui:cog-four" class="text-4" />
</button> </button>
</div> </div>
<button <button v-for="model in provider.models.filter(m => m.enabled).sort(sortByReleaseDate)"
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" :id="`model-option-${model.id}`" role="option"
:key="model.id" @click="selectModel(model, provider); isOpen = false" :aria-selected="focusedOptionId === model.id"
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" @click="selectModel(model, provider); isOpen = false"
:class="{ 'bg-[var(--color-highlight-low)]': selectedModel?.id === model.id }"> 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" /> <ModelInfo :model="model" size="medium" />
</button> </button>
</div> </div>
</div> </div>
<div class="p-1 border-t border-[var(--color-highlight-low)]"> <div class="p-1 border-t border-[var(--color-border)]">
<button <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');"> @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> <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> </button>
</div> </div>
</div> </div>
+44 -140
View File
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto'; 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 { useSettings } from '~/composables/useSettings';
import ModelItem from './ModelItem.vue'; import ModelItem from './ModelItem.vue';
// @ts-ignore // @ts-ignore
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller' import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const triplit = useTriplitClient(); const triplit = useTriplitClient();
const { pageParams } = useSettings(); const { pageParams } = useSettings();
@@ -96,29 +96,17 @@ const updateProxyUrl = async (value: string) => {
const fetchingModels = ref(false); const fetchingModels = ref(false);
const fetchModels = async () => { const fetchModels = async () => {
const { user } = useAuth(); const { user } = useAuth()
fetchingModels.value = true; fetchingModels.value = true;
try { try {
const [providerResponse, devDataResponse] = await Promise.all([ const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, {
$fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
providerApiKey: apiKey.value providerApiKey: apiKey.value
}) })
}) as any, }) 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 existingModelsMap = new Map( const existingModelsMap = new Map(
(provider.value?.models || []).map((m: any) => [m.externalId, m]) (provider.value?.models || []).map((m: any) => [m.externalId, m])
@@ -127,103 +115,34 @@ const fetchModels = async () => {
const toInsert: any[] = []; const toInsert: any[] = [];
const toUpdate: { id: string, data: any }[] = []; const toUpdate: { id: string, data: any }[] = [];
providerResponse.models.forEach((pModel: any) => { for (const model of modelsData.models) {
let slug: string = pModel.id.toLowerCase(); const existing = existingModelsMap.get(model.id);
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);
if (existing) { if (existing) {
// UPDATE logic: Remove 'id' from the payload as per Triplit requirements const { id, ...existingWithoutId } = model;
const { id, ...existingWithoutId } = existing; console.log("existingWithoutId", existingWithoutId);
toUpdate.push({ toUpdate.push({
id: existing.id, id: existing.id,
data: { data: existingWithoutId,
...existingWithoutId,
name: existing.name || pModel.name || info.name || pModel.id,
cost,
attributes, // Update tech specs
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
}
}); });
} else { } else {
// INSERT logic: This is a brand new model
toInsert.push({ toInsert.push({
userId: user.value?.id, userId: user.value?.id!,
providerId: provider.value!.id, externalId: model.id,
externalId: pModel.id, providerId: provider.value!.id!,
name: pModel.name || info.name || pModel.id, name: model.name || model.id,
cost: model.cost || {},
attributes: model.attributes,
isCustom: false, isCustom: false,
enabled: false, enabled: false,
cost, releasedAt: model.releasedAt,
attributes,
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
createdAt: new Date(),
}); });
} }
}); }
// delete models that are not in the API response and are not custom models // 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) => const toDelete = (provider.value?.models || []).filter((m: any) =>
!m.isCustom && !apiModelIds.has(m.externalId) !m.isCustom && !apiModelIds.has(m.externalId)
); );
@@ -250,12 +169,12 @@ const deleteModels = async () => {
const enabledModels = computed(() => const enabledModels = computed(() =>
filterModels(provider.value?.models.filter(m => m.enabled === true) as Model[] || [], modelSearch.value) 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(() => const disabledModels = computed(() =>
filterModels(provider.value?.models.filter(m => m.enabled === false) as Model[] || [], modelSearch.value) 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(() => { onUnmounted(() => {
@@ -274,13 +193,13 @@ defineEmits(['navigate']);
<div class="flex flex-row justify-between gap-16"> <div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">API Key</label> <label class="whitespace-nowrap" for="provider-api-key">API Key</label>
<div <div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10"> <input class="placeholder:text-[var(--text-tertiary)] w-full p-0 pl-2 py-1 bg-transparent"
<input class="w-full p-0 pl-2 py-1 bg-transparent" :type="apiKeyVisible ? 'text' : 'password'" :type="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :value="apiKey"
id="provider-api-key" :value="apiKey" autocomplete="false" spellcheck="false" autocomplete="false" spellcheck="false"
@input="updateApiKey(($event.target! as HTMLInputElement).value)" /> @input="updateApiKey(($event.target! as HTMLInputElement).value)" />
<button @click="apiKeyVisible = !apiKeyVisible" <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" /> <Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4 min-h-4 min-w-4" />
</button> </button>
</div> </div>
@@ -288,16 +207,16 @@ defineEmits(['navigate']);
<div class="flex flex-row justify-between gap-16"> <div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label> <label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
<div <div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
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] ?? ''"
<input :placeholder="providerBaseUrls[provider!.type] ?? ''" class="w-full px-2 py-1 bg-transparent" class="placeholder:text-[var(--text-tertiary)] w-full px-2 py-1 bg-transparent" type="text"
type="text" id="provider-proxy-url" :value="apiProxyUrl" id="provider-proxy-url" :value="apiProxyUrl"
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" /> @input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
</div> </div>
</div> </div>
<div class="flex flex-row justify-center text-xs"> <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 <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. href="https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01">AES-GCM</a> encryption.
</p> </p>
@@ -307,26 +226,27 @@ defineEmits(['navigate']);
<div class="pt-5 justify-between w-full flex flex-wrap gap-y-1 items-center"> <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"> <h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
Model List Model List
<span class="text-sm text-[var(--color-muted)] font-normal text-xs flex items-center gap-1"> <span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
{{ provider?.models.length }} models available <button @click="deleteModels"> {{ 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" /> <Icon name="mynaui:x-solid" />
</button> </button>
</span> </span>
</h4> </h4>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div class="flex items-center justify-center px-2 py-1 bg-[var(--bg-container)] text-xs rounded-md">
class="flex items-center justify-center px-2 py-1 bg-[var(--color-highlight)] text-xs rounded-md"> <input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch"
<input class="p-0 bg-transparent" v-model="modelSearch" type="text" type="text" placeholder="Search models..." />
placeholder="Search models..." />
<button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''" <button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''"
class="right-1 hover:bg-[var(--color-highlight)] rounded p-0.5"> 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(--color-subtle)]" /> <Icon name="mynaui:x" class="text-3.5 block text-[var(--text-secondary)]" />
</button> </button>
</div> </div>
<button @click="fetchModels" <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" /> <Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
fetch models fetch models
</button> </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"> <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" /> <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 No models found
</span> </span>
</div> </div>
<ClientOnly v-else> <ClientOnly v-else>
<div class="flex flex-col gap-1 mt-2"> <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 Enabled
</span> </span>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -359,7 +279,7 @@ defineEmits(['navigate']);
</template> </template>
</DynamicScroller> </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 Disabled
</span> </span>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -383,19 +303,3 @@ defineEmits(['navigate']);
</div> </div>
</div> </div>
</template> </template>
<style>
.animate-rotate {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
+79 -1
View File
@@ -1,3 +1,81 @@
<template> <script setup lang="ts">
const { settings, updateSettings } = await useUserSettings();
const { colorScheme } = useTheme();
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> </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-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"> 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 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"> overflow-hidden flex max-h-[90vh] p-2">
<nav class="w-64 flex flex-col gap-1 mr-2 overflow-y-auto"> <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 --> <!-- 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" /> <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)" <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"> <div class="flex items-center gap-2 max-w-full flex-1">
<Icon :name="config.icon" class="w-5 h-5" /> <Icon :name="config.icon" class="w-5 h-5" />
{{ config.label }} {{ config.label }}
@@ -114,11 +114,11 @@ onUnmounted(() => {
<!-- DYNAMIC CONTENT --> <!-- DYNAMIC CONTENT -->
<main class="flex-1 flex flex-col overflow-hidden"> <main class="flex-1 flex flex-col overflow-hidden">
<div <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 "> <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 <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"> @click="close">
<Icon name="mynaui:x-solid" /> <Icon name="mynaui:x-solid" />
</button> </button>
@@ -3,4 +3,5 @@ defineEmits(['navigate']);
</script> </script>
<template> <template>
hi
</template> </template>
+1 -3
View File
@@ -1,6 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
const triplit = useTriplitClient();
const props = defineProps<{ const props = defineProps<{
model: ModelWithProvider; model: ModelWithProvider;
}>(); }>();
@@ -8,7 +6,7 @@ const props = defineProps<{
<template> <template>
<div <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" <ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
:show-release-date="true" /> :show-release-date="true" />
</div> </div>
+26 -10
View File
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Providers } from '~/types/model'; import { Providers } from '~/types/model';
import { providerIcons } from '~/utils/model-mapping';
const triplit = useTriplitClient(); const triplit = useTriplitClient();
const { providers, unsubscribe: unsubscribeModels } = await useModels(); const { providers, unsubscribe: unsubscribeModels } = await useModels();
if (providers.value === undefined) throw new Error('Providers not loaded'); if (providers.value === undefined) throw new Error('Providers not loaded');
// TODO: sometimes this code can create duplicate providers
const { user } = useAuth(); const { user } = useAuth();
for (const provider of Providers) { for (const provider of Providers) {
if (!providers.value?.find(p => p.type === provider)) { 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 toggleProvider = async (id: string) => {
const provider = providers.value!.find(p => p.id === id); const provider = providers.value!.find(p => p.id === id);
if (!provider) return; if (!provider) return;
@@ -39,7 +47,7 @@ defineEmits(['navigate']);
<template> <template>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h2 class="text-lg font-semibold flex items-center gap-2"> <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}} {{providers?.filter(p => p.enabled).length}}
</span> </span>
</h2> </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))]"> 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)" <button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
:key="p.id" :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"> <div class="flex flex-col flex-grow">
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3> <div class="flex items-center gap-2 mb-2">
<hr class="border-t border-[var(--color-highlight)]" /> <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>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<!-- <input type="checkbox" <!-- <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)" /> <Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div> </div>
</button> </button>
</div> </div>
<h2 class="text-lg font-semibold flex items-center gap-2"> <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}} {{providers?.filter(p => !p.enabled).length}}
</span> </span>
</h2> </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))]"> 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)" <button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
:key="p.id" :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"> <div class="flex flex-col flex-grow">
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3> <div class="flex items-center gap-2 mb-2">
<hr class="border-t border-[var(--color-highlight)]" /> <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>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<!-- <input type="checkbox" <!-- <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)" /> <Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div> </div>
</button> </button>
+14 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { providerIcons } from '~/utils/model-mapping';
const { pageParams } = useSettings(); const { pageParams } = useSettings();
const { providers, unsubscribe: unsubscribeModels } = await useModels(); const { providers, unsubscribe: unsubscribeModels } = await useModels();
@@ -12,28 +14,36 @@ defineEmits(['navigate']);
<template> <template>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<button @click="$emit('navigate', 'general')" <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 <Icon name="mynaui:chevron-left" class="text-4" /> Back to General
</button> </button>
<button @click="$emit('navigate', 'providers')" <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 <Icon name="mynaui:envelope-open" class="text-4" /> All
</button> </button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div> <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)" <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)]' : '']"> :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> <span>{{ p.name }}</span>
</div>
</button> </button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div> <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" <button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
@click="$emit('navigate', 'providers', 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)]' : '']"> :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> <span>{{ p.name }}</span>
</div>
</button> </button>
</div> </div>
</template> </template>
+14 -22
View File
@@ -1,31 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
const triplit = useTriplitClient(); import type { ModelWithProvider } from '~/composables/useModels';
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels();
const { settings, unsubscribe: unsubscribeSettings } = await useUserSettings(); const { providers, allModels } = await useModels();
const { settings, updateSettings } = await useUserSettings();
const toggle = async (key: string) => { const toggle = async (key: string) => {
console.log(key); const current = (settings.value.systemAssistants as any)[key];
await updateSettings({
await triplit.update('settings', settings.value.id, {
systemAssistants: { systemAssistants: {
...settings.value.systemAssistants,
[key]: { [key]: {
// @ts-ignore ...current,
...settings.value.systemAssistants[key], enabled: !current.enabled
// @ts-ignore
enabled: !settings.value.systemAssistants[key].enabled
} }
} }
}); });
}; };
const updateModel = async (key: string, model: ModelWithProvider | undefined | null) => { 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: { systemAssistants: {
...settings.value.systemAssistants,
[key]: { [key]: {
// @ts-ignore ...current,
...settings.value.systemAssistants[key],
modelId: model?.id ?? null modelId: model?.id ?? null
} }
} }
@@ -40,22 +36,18 @@ const getModel = (id: string | null | undefined) => {
defineEmits(['navigate']); defineEmits(['navigate']);
onUnmounted(() => {
unsubscribeModels?.();
unsubscribeSettings?.();
});
</script> </script>
<template> <template>
<div class="flex flex-col gap-4 flex-grow"> <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"> <label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
<h4 class="capitalize">{{ key }}</h4> <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> </label>
<div class="flex-1 justify-between gap-4 items-center"> <div class="flex-1 justify-between gap-4 items-center">
<ModelSelector :providers="providers" :model-value="getModel(systemAssistant.modelId)" <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> </div>
</div> </div>
+51 -29
View File
@@ -10,6 +10,9 @@ const { user, signOut } = useAuth();
// particular) // particular)
const cachedUser = ref(user.value); const cachedUser = ref(user.value);
const dropdownRef = ref<HTMLDivElement | null>(null);
const dropdownOpen = ref(false);
watch(user, () => { watch(user, () => {
if (user.value === null) return; if (user.value === null) return;
@@ -20,8 +23,6 @@ const { toggle: toggleSettings } = useSettings();
const { isHovered } = useSidenavContext(); const { isHovered } = useSidenavContext();
const profileOpen = ref(false);
const handleLogout = async () => { const handleLogout = async () => {
await signOut(); await signOut();
assert('disconnect' in triplit); assert('disconnect' in triplit);
@@ -30,41 +31,62 @@ const handleLogout = async () => {
await navigateTo('/auth/login'); await navigateTo('/auth/login');
}; };
const profileItems: DropdownItem[] = [ useClickOutside(dropdownRef, () => {
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings }, dropdownOpen.value = false;
{ });
label: 'Log out',
icon: 'mynaui:logout',
divider: true,
onClick: handleLogout,
},
];
</script> </script>
<template> <template>
<header class="flex items-center justify-between overflow-hidden"> <header ref="dropdownRef" class="flex items-center justify-between overflow-hidden">
<Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left" <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%"> verticality="descending" width="100%">
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<button aria-label="open user dropdown" <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"> @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> </button>
</template> </template>
</Dropdown> </Dropdown> -->
</header> </header>
</template> </template>
+53 -14
View File
@@ -1,20 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown';
const route = useRoute(); const route = useRoute();
const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents(); const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const activeAgent = computed(() => getAgent(route.params.id as string)); const activeAgent = computed(() => getAgent(route.params.id as string));
const { isHovered } = useSidenavContext(); const { isHovered, sidebarWidth } = useSidenavContext();
onUnmounted(() => { onUnmounted(() => {
unsubscribeAgents?.(); 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> </script>
<template> <template>
@@ -22,41 +41,61 @@ const agentItems: DropdownItem[] = [];
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'" <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']"> :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="/" <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" /> <Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
</NuxtLink> </NuxtLink>
</div> </div>
<Dropdown class="overflow-hidden" v-model="agentDropdownOpen" :items="agentItems" placement="center" <button
width="calc(80% - 1rem)"> 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 }"> <template #trigger="{ toggle }">
<button <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"> @click="toggle">
<div <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" <img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
class="w-full h-full object-cover" /> class="w-full h-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" /> <Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div> </div>
<span <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 }} {{ activeAgent?.name }}
</span> </span>
<div class="w-4 h-4 text-[var(--color-muted)]"> <div class="w-4 h-4 text-[var(--text-secondary)]">
<Icon <Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all" 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" /> name="mynaui:chevron-up-down" />
</div> </div>
</button> </button>
</template> </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"> <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" <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" class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
:active="activeAgent?.id === agent.id" /> :active="activeAgent?.id === agent.id" />
</div> </div>
</template> </template>
</Dropdown> </Dropdown> -->
</header> </header>
</template> </template>
+5 -5
View File
@@ -9,11 +9,11 @@ const props = defineProps<{
<template> <template>
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[ <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.icon ? 'px-1' : 'px-2',
props.active props.active
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]' : '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 items-center gap-2 max-w-full flex-1">
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center"> <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', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
props.icon ? 'px-1' : 'px-2', props.icon ? 'px-1' : 'px-2',
props.active props.active
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]' : '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 items-center gap-2 max-w-full flex-1">
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center"> <div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
+163 -124
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { assert } from '~~/utils/assert';
const route = useRoute(); const route = useRoute();
const topicsListRef = ref<HTMLElement | null>(null); const { openDropdown, dropdownState, closeDropdown } = useDropdown();
const topicsListHeight = ref('auto');
const topicsListOpacity = ref(1);
const topicsListScale = ref(1);
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents(); const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const triplit = useTriplitClient(); const triplit = useTriplitClient();
@@ -12,97 +11,88 @@ const activeAgent = computed(() => getAgent(route.params.id as string));
const topics = computed(() => { const topics = computed(() => {
if (activeAgent.value === undefined) return []; 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;
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';
}
}); });
const topicsOpen = ref(true); const topicsOpen = ref(true);
let activeAutoRenames = reactive(new Map<string, string>());
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);
};
const autoRenameTopic = async (topicId: string) => { const autoRenameTopic = async (topicId: string) => {
const { setPage } = useSettings(); 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)); const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
if (!firstMessage) return; if (!firstMessage) return;
const success = await autoRename(topicId, firstMessage.content); const res = await autoRename(topicId, firstMessage.content);
if (!success) { if (res.ok) {
activeAutoRenames.set(topicId, res.data);
return;
}
switch (res.error) {
case AutoRenameError.NoModelSelected:
case AutoRenameError.ModelDisabled:
case AutoRenameError.AutoRenameDisabled: {
setPage('systemAssistants'); 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) => { const cancelAutoRename = async (topicId: string) => {
console.log('renameTopic', topicId); 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) => { 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 // 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(() => { onUnmounted(() => {
unsubscribeAgents?.(); unsubscribeAgents?.();
}); });
@@ -128,69 +182,54 @@ onUnmounted(() => {
<!-- Agent Info Link --> <!-- Agent Info Link -->
<div class="mt-2 flex flex-col"> <div class="mt-2 flex flex-col">
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square" <SidenavItem :to="`/agent/${route.params.id}/profile`" name="Agent Info" icon="mynaui:info-square"
:active="pageInfo === 'agent-profile'" /> :active="route.path.endsWith('/profile')" />
<!-- Topics Section --> <!-- Topics Section -->
<button @click="toggleAgentsList" <button @click="topicsOpen = !topicsOpen"
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"> class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
<span class="text-sm font-medium">Topics</span> <span class="text-sm font-medium">Topics</span>
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" /> <Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
</button> </button>
<div ref="topicsListRef" :inert="!topicsOpen" :class="{ 'overflow-y-hidden': !topicsOpen }" <Collapsible :is-open="topicsOpen">
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }" <div @click="handleNavClick"
class="mt-1 gap-1 flex flex-col transform-origin-center-top"> class="mt-1 gap-1 flex flex-col transform-origin-center-top [content-visibility:auto] [contain-intrinsic-size:0_36px]">
<NuxtLink v-if="activeAgent?.topics !== undefined" v-for="topic in topics" <a v-for="topic in topics" :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[ :href="`/agent/${route.params.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', 'group relative decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
'px-2', 'px-2',
topic.id === route.params.topicId 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)]' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]' : '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 items-center gap-2 max-w-full flex-1">
<div v-if="!topic.renaming" class="flex justify-between items-center w-full"> <div class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"> <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 }} {{ topic.name }}
</span> </span>
<Dropdown class="shrink-0 text-[var(--color-text)]" verticality="descending" <div data-action="toggle-dropdown"
placement="right"> 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)]">
<template #trigger="{ toggle }"> <svg class="pointer-events-none" xmlns="http://www.w3.org/2000/svg" width="18"
<div dots @click.prevent="toggle" height="18" viewBox="0 0 24 24">
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" <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" /> 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> </svg>
</div> </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>
<div v-else class="flex w-full">
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
</div> </div>
</div> </div>
</NuxtLink> </a>
</div> </div>
</Collapsible>
</div> </div>
</nav> </nav>
</template> </template>
+21 -74
View File
@@ -1,97 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents(); const { agents, unsubscribe, createAgent } = await useAgents();
const agentsListRef = ref<HTMLElement | null>(null);
const agentsOpen = ref(true); const agentsOpen = ref(true);
const agentsListHeight = ref('auto');
const agentsListOpacity = ref(1);
const agentsListScale = ref(1);
const creatingAgent = ref(false); const creatingAgent = ref(false);
function easeInOutQuad(x: number): number {
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
}
const toggleAgentsList = () => { 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; 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 newAgent = async () => {
const triplit = useTriplitClient(); creatingAgent.value = true;
const { user } = useAuth(); try {
if (!user.value) throw new Error('User not logged in');
const agent = await createAgent(); const agent = await createAgent();
if (!agent) throw new Error('Failed to create agent'); if (agent) return navigateTo(`/agent/${agent.id}`);
} finally {
return navigateTo(`/agent/${agent.id}`); creatingAgent.value = false;
}
}; };
onUnmounted(() => { onUnmounted(() => unsubscribe?.());
unsubscribeAgents?.();
});
</script> </script>
<template> <template>
<nav class="flex flex-col gap-1"> <nav class="flex flex-col gap-1">
<SidenavItem name="Search" icon="mynaui:search" /> <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 <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" 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()"> @click="toggleAgentsList">
<span class="text-sm">Agents</span> <span class="text-sm">Agents</span>
<Icon name="mynaui:chevron-down" <Icon name="mynaui:chevron-down" class="w-4 h-4 transition-transform duration-200"
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" /> :class="{ '-rotate-90': !agentsOpen }" />
</button> </button>
<div ref="agentsListRef" :inert="!agentsOpen" <!-- Animated Section -->
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }" <Collapsible :is-open="agentsOpen">
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden"> <div class="mt-1 gap-1 flex flex-col transform-origin-top pt-1 pb-1 px-1">
<button @click="newAgent" :disabled="creatingAgent" <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"> 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"> <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-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
<Icon v-else class="text-4.5" name="mynaui:plus" /> <Icon v-else class="text-4.5" name="mynaui:plus" />
@@ -99,9 +46,9 @@ onUnmounted(() => {
<span>New Agent</span> <span>New Agent</span>
</button> </button>
<SidenavItem <SidenavItem v-for="agent in agents" :key="agent.id" :to="`/agent/${agent.id}`" :name="agent.name"
v-for="agent in agents?.map((agent) => agent)?.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())" icon="mynaui:check-hexagon" />
:to="`/agent/${agent.id}`" :key="agent.id" :name="agent.name" icon="mynaui:check-hexagon" />
</div> </div>
</Collapsible>
</nav> </nav>
</template> </template>
+19 -46
View File
@@ -68,15 +68,18 @@ const onResizeEnd = () => {
let resizeAnimationFrame: number | null = null; let resizeAnimationFrame: number | null = null;
const trackInteractionMouse = () => trackInteraction('mouse');
const trackInteractionKeyboard = (e: KeyboardEvent) => {
if (e.key === 'Tab') trackInteraction('keyboard');
};
onMounted(() => { onMounted(() => {
document.addEventListener('mousemove', onResizeMove); document.addEventListener('mousemove', onResizeMove);
document.addEventListener('mouseup', onResizeEnd); document.addEventListener('mouseup', onResizeEnd);
// NEW: Track global interactions // NEW: Track global interactions
document.addEventListener('mousedown', () => trackInteraction('mouse')); document.addEventListener('mousedown', trackInteractionMouse);
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', trackInteractionKeyboard);
if (e.key === 'Tab') trackInteraction('keyboard');
});
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -84,10 +87,8 @@ onUnmounted(() => {
document.removeEventListener('mouseup', onResizeEnd); document.removeEventListener('mouseup', onResizeEnd);
// NEW: Cleanup listeners // NEW: Cleanup listeners
document.removeEventListener('mousedown', () => trackInteraction('mouse')); document.removeEventListener('mousedown', trackInteractionMouse);
document.removeEventListener('keydown', (e) => { document.removeEventListener('keydown', trackInteractionKeyboard);
if (e.key === 'Tab') trackInteraction('keyboard');
});
}); });
const navKind = computed(() => { const navKind = computed(() => {
@@ -100,9 +101,9 @@ const navKind = computed(() => {
<template> <template>
<div class="relative"> <div class="relative">
<aside ref="sidenavRef" :class="[ <aside ref="sidenavRef" :class="[
'sidenav', '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 ? 'sidenav--open' : 'sidenav--closed', open ? 'w-full mr-2' : 'w-0 mr-0',
isResizing ? 'sidenav--resizing' : '' isResizing ? 'transition-none' : ''
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true" ]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut"> @mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between"> <div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
@@ -113,11 +114,11 @@ const navKind = computed(() => {
<SidenavHeader v-if="navKind === 'home'" /> <SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" /> <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;'" <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']"> :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="[ <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" <Icon name="mynaui:panel-left-close"
:class="['transition-inherit transform-origin-right-center', isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" /> :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>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg"> <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="[ <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" /> <Icon name="mynaui:book-plus" />
</NuxtLink> </NuxtLink>
@@ -134,7 +135,8 @@ const navKind = computed(() => {
</div> </div>
<!-- Main Menu --> <!-- 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'" /> <SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" /> <SidenavNavAgent v-else-if="navKind === 'agent'" />
</div> </div>
@@ -143,12 +145,11 @@ const navKind = computed(() => {
<div class="flex justify-between pt-2"> <div class="flex justify-between pt-2">
<div class="flex"> <div class="flex">
<button @click="toggleSettings()" <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)]"> 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-4" /> <Icon name="mynaui:cog-four" class="text-5" />
</button> </button>
</div> </div>
<!-- Theme Switcher -->
<div class="flex justify-end gap-1"> <div class="flex justify-end gap-1">
<ThemeSwitcher /> <ThemeSwitcher />
</div> </div>
@@ -161,31 +162,3 @@ const navKind = computed(() => {
</div> </div>
</div> </div>
</template> </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> </script>
<template> <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" :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" :tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active"
:data-state="(active) ? 'checked' : 'unchecked'"> :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> </button>
</template> </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>
+22 -18
View File
@@ -3,34 +3,38 @@ import type { DropdownItem } from '~/types/dropdown';
type Theme = 'light' | 'dark' | 'system'; 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[] = [ const themeOptions: DropdownItem[] = [
{ value: 'light', label: 'Light', icon: 'mynaui:sun' }, { id: 'light', label: 'Light', icon: 'mynaui:sun', onClick: () => selectTheme('light') },
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' }, { id: 'dark', label: 'Dark', icon: 'mynaui:moon', onClick: () => selectTheme('dark') },
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }, { id: 'system', label: 'System', icon: 'mynaui:desktop', onClick: () => selectTheme('system') },
]; ];
const currentOption = computed( 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) => { if (dropdownState.open) {
colorMode.preference = item.value as Theme; closeDropdown();
}; return;
}
openDropdown(e, () => themeOptions, { verticality: 'ascending', placement: 'right' });
}
</script> </script>
<template> <template>
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending" <button aria-label="Open theme switcher" @click="toggleDropdown"
placement="right" width="140px"> class="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)]">
<template #trigger="{ toggle, isOpen }"> <Icon :name="currentOption!.icon!" class="text-5 pointer-events-none" />
<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> </button>
</template>
</Dropdown>
</template> </template>
+8 -1
View File
@@ -4,7 +4,14 @@ import type { Entity } from "@triplit/client";
export const useAgents = async () => { export const useAgents = async () => {
const triplit = useTriplitClient(); 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 createAgent = async (): Promise<Readonly<Entity<typeof schema, 'agents'>> | null> => {
const { user } = useAuth(); const { user } = useAuth();
+58 -11
View File
@@ -1,8 +1,9 @@
import type schema from "#triplit/schema"; import type schema from "#triplit/schema";
import type { Entity } from "@triplit/client"; import type { Entity } from "@triplit/client";
import type { ModelMessage } from "ai"; import type { ModelMessage } from "ai";
import { nanoid } from "nanoid";
import { decrypt, base64ToUint8Array } from "~/utils/crypto"; 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"; import { assert } from "~~/utils/assert";
export type MessageEntity = Entity<typeof schema, 'messages'> & { export type MessageEntity = Entity<typeof schema, 'messages'> & {
@@ -22,6 +23,7 @@ export enum ChatErrorType {
NoAgent, NoAgent,
NoUser, NoUser,
DatabaseOperationFailed, DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
GenerationFailed, GenerationFailed,
MarshallFailed, MarshallFailed,
NoProviderApiKey, NoProviderApiKey,
@@ -195,6 +197,7 @@ export const useChat = (agentId: string) => {
): Promise<Result<void, ChatErrorType>> => { ): Promise<Result<void, ChatErrorType>> => {
let providerApiKey: string | undefined = undefined; let providerApiKey: string | undefined = undefined;
if (provider.config.apiKey !== undefined) { if (provider.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
"jwk", "jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!), JSON.parse(window.localStorage.getItem("encryptionKey")!),
@@ -207,10 +210,14 @@ export const useChat = (agentId: string) => {
key, key,
base64ToUint8Array(provider.config.apiKey) base64ToUint8Array(provider.config.apiKey)
); );
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
}
} }
try { try {
$fetch('/api/chat/generate', { await $fetch('/api/chat/generate', {
method: 'POST', method: 'POST',
body: { body: {
messages, messages,
@@ -249,14 +256,17 @@ export const useChat = (agentId: string) => {
return Err(ChatErrorType.NoUser); return Err(ChatErrorType.NoUser);
} }
const messageId = nanoid();
const newMessage = await triplit.insert('messages', { const newMessage = await triplit.insert('messages', {
id: messageId,
userId: user.value.id, userId: user.value.id,
topicId: topic.id, topicId: topic.id,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
content: message, content: message,
role: 'user', role: 'user',
}).catch(error => { }).catch(async error => {
console.error('Failed to insert message:', error); console.error('Failed to insert message:', error);
await triplit.delete('messages', messageId);
return Err(ChatErrorType.DatabaseOperationFailed); return Err(ChatErrorType.DatabaseOperationFailed);
}) as Message; }) as Message;
@@ -278,7 +288,14 @@ export const useChat = (agentId: string) => {
presence_penalty: 0, 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,31 +390,51 @@ export const useChat = (agentId: string) => {
return startGeneration(messages.data, args, topic, provider, model, parentMessageId); 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(); const { settings } = await useUserSettings();
console.log(settings.value); console.log(settings.value);
if (!settings.value.systemAssistants.rename.enabled) { if (!settings.value.systemAssistants.rename.enabled) {
return false; return Err(AutoRenameError.AutoRenameDisabled);
} }
if (!settings.value.systemAssistants.rename.modelId) { if (!settings.value.systemAssistants.rename.modelId) {
return false; return Err(AutoRenameError.NoModelSelected);
} }
await triplit.update('topics', topicId, { await triplit.update('topics', topicId, {
renaming: true 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) { 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; let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) { if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
"jwk", "jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!), JSON.parse(window.localStorage.getItem("encryptionKey")!),
@@ -410,9 +447,14 @@ export const useChat = (agentId: string) => {
key, key,
base64ToUint8Array(model.provider!.config.apiKey) 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`, { try {
const res = await $fetch(`/api/topic/auto-rename`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
modelId: model.id, modelId: model.id,
@@ -422,11 +464,16 @@ export const useChat = (agentId: string) => {
}), }),
}); });
return true; return Ok(res.renameId);
} catch (error) {
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
} }
return { return {
sendMessage, sendMessage,
AutoRenameError,
autoRename, autoRename,
regenerateMessage, regenerateMessage,
createTopic, 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') .query('providers')
.Include('models'); .Include('models');
const start = Date.now();
nuxtApp._modelsPromise = useQuery('providers', triplit, providersQuery).then((sub) => { nuxtApp._modelsPromise = useQuery('providers', triplit, providersQuery).then((sub) => {
nuxtApp._modelsSubscription = sub; nuxtApp._modelsSubscription = sub;
console.log("fetching providers took", Date.now() - start);
return sub; return sub;
}); });
} }
+1 -1
View File
@@ -3,7 +3,7 @@ export const useSidebar = () => {
const sidebarWidth = useState<number>('sidebar:width', () => { const sidebarWidth = useState<number>('sidebar:width', () => {
return Number( return Number(
useCookie('sidebar:width', { useCookie('sidebar:width', {
default: () => '226', default: () => '250',
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30,
}).value, }).value,
); );
+55
View File
@@ -13,9 +13,64 @@ export const useTheme = () => {
maxAge: 60 * 60 * 24 * 365, 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 { return {
accent, accent,
neutral, neutral,
hinting, 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 () => { export const useUserSettings = async () => {
const user = useAuth().user; const { user, loggedIn } = useAuth();
const triplit = useTriplitClient(); 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> <template>
<div class="h-full w-full grid place-items-center"> <div class="h-full w-full grid place-items-center">
<div <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 /> <slot />
</div> </div>
</div> </div>
@@ -9,14 +9,14 @@
<style> <style>
input { input {
color: var(--color-text); color: var(--text-primary);
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
border-radius: calc(var(--spacing) * 1.5); border-radius: calc(var(--spacing) * 1.5);
width: 100%; width: 100%;
background-color: var(--color-input); background-color: var(--bg-container);
transition-property: color, border, background-color; transition-property: color, border, background-color;
transition-duration: 300ms; transition-duration: 300ms;
+11 -2
View File
@@ -2,17 +2,25 @@
import SettingsDialog from '~/components/Settings/Dialog.vue'; import SettingsDialog from '~/components/Settings/Dialog.vue';
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { colorScheme } = useTheme();
useHead({
htmlAttrs: {
class: colorScheme.class
}
});
useKeyboardShortcuts(); useKeyboardShortcuts();
</script> </script>
<template> <template>
<Sidenav /> <Sidenav />
<main <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="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar" <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" /> <Icon class="text-5" name="mynaui:panel-left-open" />
</button> </button>
</div> </div>
@@ -26,4 +34,5 @@ useKeyboardShortcuts();
</div> </div>
</main> </main>
<SettingsDialog /> <SettingsDialog />
<Dropdown />
</template> </template>
+43 -19
View File
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
const triplit = useTriplitClient();
const route = useRoute(); const route = useRoute();
const inputValue = ref('');
const pendingMessage = ref<Message | null>(null); const pendingMessage = ref<Message | null>(null);
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string); 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(); const topic = await createTopic();
if (!topic) throw new Error('Failed to create topic'); 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}`); 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(() => { onUnmounted(() => {
@@ -64,28 +94,22 @@ onUnmounted(() => {
<template> <template>
<div class="h-full w-full"> <div
<!-- chat pane --> 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="flex flex-col w-full px-4 overflow-y-auto h-full" <div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane"> <div class="w-full px-px flex flex-col flex-grow gap-2"
<div class="flex-grow w-full flex justify-center"> :class="pendingMessage === null ? 'justify-end pb-28' : 'pb-9'">
<div class="flex h-full max-w-4xl w-full flex-col gap-2"
:class="pendingMessage === null ? 'justify-end' : ''">
<div v-if="pendingMessage === null"> <div v-if="pendingMessage === null">
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1> <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> <p class="text-[var(--text-secondary)]">Select a topic to continue or create a new one</p>
</div> </div>
<div class="opacity-70" v-else> <div v-else class="opacity-70">
<Message :message="pendingMessage" /> <Message :message="pendingMessage" />
</div> </div>
</div> </div>
</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"
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center"> :agent="agent" :providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" />
<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> </div>
</div> </div>
</div> </div>
+3 -3
View File
@@ -49,13 +49,13 @@ onUnmounted(() => {
<Icon v-else name="mynaui:check-hexagon" class="text-16" /> <Icon v-else name="mynaui:check-hexagon" class="text-16" />
</div> </div>
<input @input="handleInput" placeholder="Agent Name..." <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" /> type="text" :value="agent?.name" />
</div> </div>
<div class="flex flex-col gap-2 w-full h-full mb-14"> <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." <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> :value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
</div> </div>
</div> </div>
+78 -65
View File
@@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Message, MessageEntity } from '~/composables/useChat'; import type schema from '#triplit/schema';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels'; import type { Entity } from '@triplit/client';
import type { ModelWithProvider } from '~/composables/useModels';
const rootStart = Date.now();
const triplit = useTriplitClient(); const triplit = useTriplitClient();
const chatPane = ref<HTMLElement | null>(null); const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref('');
const route = useRoute(); const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string); const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents(); const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels(); const { providers, allModels, unsubscribe: unsubscribeModels } = await useModels();
const agent = computed(() => { const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') { if (route.params.id === null || typeof route.params.id !== 'string') {
@@ -59,39 +62,42 @@ const [
]); ]);
const topic = computed(() => { 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 messagesMap = new Map();
const partsByMessage = new Map<string, Entity<typeof schema, 'message_parts'>[]>();
// First pass: Create message objects with parts arrays // Group parts by message ID once
if (rawParts.value) {
for (const part of rawParts.value) {
if (!partsByMessage.has(part.messageId)) {
partsByMessage.set(part.messageId, []);
}
if (part.content !== '' || part.toolCall !== null) {
partsByMessage.get(part.messageId)!.push(part);
}
}
}
const generationsMap = new Map(
rawGenerations.value?.map(g => [g.id, g]) ?? []
);
// Single pass to build messages
for (const msg of rawMessages.value) { for (const msg of rawMessages.value) {
messagesMap.set(msg.id, { messagesMap.set(msg.id, {
...msg, ...msg,
parts: [], parts: partsByMessage.get(msg.id) ?? [],
children: [], children: [],
generation: rawGenerations.value?.find(g => g.id === msg.generationId) ?? null generation: generationsMap.get(msg.generationId!) ?? null
}); });
} }
// Second pass: Attach parts to messages // Build tree
if (rawParts.value) { const rootMessages: Message[] = [];
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);
}
}
}
}
// Third pass: Build children relationships
const rootMessages = [];
for (const msg of messagesMap.values()) { for (const msg of messagesMap.values()) {
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) { if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
messagesMap.get(msg.parentMessageId).children.push(msg); messagesMap.get(msg.parentMessageId)!.children.push(msg);
} else { } else {
rootMessages.push(msg); rootMessages.push(msg);
} }
@@ -104,26 +110,7 @@ const topic = computed(() => {
}; };
}); });
if (!topic.value) navigateTo(`/agent/${route.params.id}`); const submitMessage = async (message: string, model: ModelWithProvider | null) => {
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) => {
if (!model) { if (!model) {
console.error('No model selected'); console.error('No model selected');
return; 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); const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
if (!res.ok) { if (!res.ok) {
console.error('Failed to send message:', res.error); 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; return;
} }
@@ -139,7 +134,7 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
}; };
const focusedMessageTree = computed(() => { const focusedMessageTree = computed(() => {
const tree: MessageEntity[] = []; const tree: Readonly<MessageEntity>[] = [];
for (const message of topic.value?.messages || []) { for (const message of topic.value?.messages || []) {
if (message.focusedIndex !== undefined && message.focusedIndex !== null) { if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
if (message.focusedIndex === 0) { if (message.focusedIndex === 0) {
@@ -156,7 +151,7 @@ const focusedMessageTree = computed(() => {
}) })
const handleRegenerate = async (message: Message) => { const handleRegenerate = async (message: Message) => {
if (!agent.value.defaultModelId) { if (!agent.value!.defaultModelId) {
console.error('No model selected'); console.error('No model selected');
return; return;
} }
@@ -172,7 +167,7 @@ const handleRegenerate = async (message: Message) => {
messageId = message.id; 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) { if (!model) {
console.error('Model not found'); console.error('Model not found');
return; return;
@@ -254,36 +249,54 @@ 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(() => { onUnmounted(() => {
unsubscribeTopic?.(); unsubscribeTopic?.();
unsubscribeAgents?.();
unsubscribeMessages?.(); unsubscribeMessages?.();
unsubscribeParts?.(); unsubscribeParts?.();
unsubscribeGenerations?.(); unsubscribeGenerations?.();
unsubscribeAgents?.();
unsubscribeModels?.(); unsubscribeModels?.();
}); });
</script> </script>
<template> <template>
<div class="h-full w-full">
<!-- chat pane --> <!-- chat pane -->
<div class="flex flex-col w-full px-4 overflow-y-auto h-full" <div ref="chatPaneWrapper"
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane"> 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="flex-grow w-full flex justify-center"> <div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9" <div class="w-full px-px flex flex-col flex-grow gap-2 pb-9">
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0"> <Suspense>
<Message v-for="message in topic.messages" @delete="handleDelete(message)" :key="message.id" <template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
@regenerate="handleRegenerate(message)" :message="message" /> <Message v-for="message in topic.messages" :key="message.id" :message="message"
</div> v-memo="[message.id, message.parts?.length, message.children, message.focusedIndex, message.content]"
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" />
</template>
</Suspense>
</div> </div>
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center"> <div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl"> <ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :loading="activeGeneration !== null" :agent="agent" :providers="providers?.filter(p => p.enabled)"
:loading="activeGeneration !== null" :agent="agent" @submit="submitMessage" @cancel="handleCancel" />
:providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" @cancel="handleCancel">
</ChatInput>
</div>
</div> </div>
</div> </div>
</div> </div>
+32 -27
View File
@@ -3,6 +3,8 @@ import type schema from '#triplit/schema';
import type { Entity } from '@triplit/client'; import type { Entity } from '@triplit/client';
import { assert } from '~~/utils/assert'; import { assert } from '~~/utils/assert';
const triplit = useTriplitClient();
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents(); const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
const { providers, unsubscribe: unsubscribeModels, getFirstAvailableModel, allModels } = await useModels(); 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}`); 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(() => { onMounted(() => {
@@ -142,7 +169,9 @@ onUnmounted(() => {
<template> <template>
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12"> <div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
<h1 class="text-center 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"> <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. --> <!-- 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" <ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
@@ -150,27 +179,3 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</template> </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); assert('updateOptions' in triplit);
triplit.updateOptions({ triplit.updateOptions({
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: session.value.token, token: session.value.token,
}); });
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineNuxtPlugin((nuxtApp) => {
.use(remarkGfm) .use(remarkGfm)
.use(remarkMath) .use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true }) .use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex); .use(rehypeKatex, { output: 'mathml' });
return { return {
provide: { 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 { export interface DropdownItem {
id?: string;
active?: boolean;
label: string; label: string;
icon?: string; icon?: string;
onClick?: () => void; onClick?: () => void;
value?: string | number; danger?: boolean;
disabled?: boolean; disabled?: boolean;
divider?: boolean;
} }
+1 -1
View File
@@ -10,7 +10,7 @@ export const providerBaseUrls = {
cerebras: 'https://api.cerebras.ai/v1', cerebras: 'https://api.cerebras.ai/v1',
google: 'https://generativelanguage.googleapis.com/v1beta', google: 'https://generativelanguage.googleapis.com/v1beta',
longcat: 'https://api.longcat.chat/openai/v1', 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'> }; 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; return hashHex;
} }
// simple djb2 hash
export const hashSync = (text: string) => { export const hashSync = (text: string) => {
let hash = 5381; let hash = 5381;
let i = 0; let i = 0;
+29 -13
View File
@@ -1,25 +1,34 @@
import { import {
LogoOpenAI,
LogoOpenrouter,
LogoOllama,
LogoCerebras,
LogoLongCat,
LogoGoogle,
LogoGrok, LogoGrok,
LogoGemini, LogoGemini,
LogoGemma,
LogoDeepMind,
LogoVertexAI,
LogoDeepCogito,
LogoQwen, LogoQwen,
LogoOpenAI,
LogoArcee, LogoArcee,
LogoAi2, LogoAi2,
LogoOpenrouter,
LogoMoonshot, LogoMoonshot,
LogoZAI, LogoZAI,
LogoGLMV, LogoGLMV,
LogoChatGLM, LogoChatGLM,
LogoNvidia, LogoNvidia,
LogoGemma,
LogoMistral, LogoMistral,
LogoMeta, LogoMeta,
LogoPerplexity, LogoPerplexity,
LogoDeepCogito,
LogoDeepSeek, LogoDeepSeek,
LogoClaude,
LogoAnthropic, LogoAnthropic,
LogoMorph, LogoMorph,
LogoNousResearch,
LogoAi21, LogoAi21,
LogoWenxin,
LogoBaiduCloud, LogoBaiduCloud,
LogoMinimax, LogoMinimax,
LogoNova, LogoNova,
@@ -31,24 +40,18 @@ import {
LogoXiaomiMiMo, LogoXiaomiMiMo,
LogoRelace, LogoRelace,
LogoEssentialAI, LogoEssentialAI,
LogoClaude,
LogoKwaipilot, LogoKwaipilot,
LogoIBM, LogoIBM,
LogoInternLM, LogoInternLM,
LogoLongCat,
LogoWenxin,
LogoHunyuan, LogoHunyuan,
LogoInception, LogoInception,
LogoAya,
LogoCohere, LogoCohere,
LogoAionLabs, LogoAionLabs,
LogoMicrosoft, LogoMicrosoft,
LogoInflection, LogoInflection,
LogoAya,
LogoDeepMind,
LogoVertexAI,
LogoGoogle
} from '#components'; } from '#components';
import NousResearch from '~/components/Logo/NousResearch.vue'; import { markRaw } from 'vue';
interface ModelConfig { interface ModelConfig {
Icon: any; Icon: any;
@@ -210,7 +213,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
keywords: [/^morph-/, /\/morph-/] keywords: [/^morph-/, /\/morph-/]
}, },
{ {
Icon: markRaw(NousResearch), Icon: markRaw(LogoNousResearch),
keywords: [/deephermes/, /hermes/, /genstruct/, /minos/] 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) { export function getModelConfig(modelId: string) {
const cleanId = modelId.toLowerCase(); const cleanId = modelId.toLowerCase();
-2
View File
@@ -1,9 +1,7 @@
export const initSettings = async (userId: string) => { export const initSettings = async (userId: string) => {
const triplit = useTriplitClient(); const triplit = useTriplitClient();
console.log(userId);
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId)); const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
console.log(settings);
if (!settings) { if (!settings) {
console.log('no 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: { session: {
expiresIn: 60 * 60 * 24 * 30, // 1 month
updateAge: 60 * 60 * 24 * 1, // 1 day
cookieCache: { cookieCache: {
enabled: true, enabled: true,
maxAge: 5 * 60, 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', '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: [ 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: ` 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: { optimizeDeps: {
include: [ 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-core',
'@vue/devtools-kit', '@vue/devtools-kit',
'@sentry/nuxt',
'@nuxt/hints/runtime/hydration/component', '@nuxt/hints/runtime/hydration/component',
'vue-virtual-scroller', 'vue-virtual-scroller',
'shiki', 'shiki',
@@ -59,9 +119,17 @@ export default defineNuxtConfig({
'remark-rehype', 'remark-rehype',
'remark-math', 'remark-math',
'rehype-katex', 'rehype-katex',
'unist-util-visit',
'@triplit/client', '@triplit/client',
'@triplit/db' '@triplit/db',
'comlink'
], ],
},
},
nitro: {
experimental: {
websocket: true,
} }
}, },
@@ -70,13 +138,7 @@ export default defineNuxtConfig({
// viewTransition: true, // viewTransition: true,
// }, // },
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode', 'triplit-nuxt', 'nuxt-shiki', '@sentry/nuxt/module'], modules: ['@vue-macros/nuxt', '@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', 'triplit-nuxt', '@sentry/nuxt/module', '@nuxt/fonts'],
shiki: {
bundledThemes: ['vitesse-dark', 'vitesse-light'],
bundledLangs: ['js', 'jsx', 'json', 'ts', 'tsx', 'vue', 'css', 'html', 'bash', 'md', 'mdc', 'yaml', 'py'],
defaultTheme: 'vitesse-dark',
},
triplit: { triplit: {
schema_path: './triplit/schema.ts', schema_path: './triplit/schema.ts',
@@ -87,12 +149,6 @@ export default defineNuxtConfig({
autoConnect: false, autoConnect: false,
}, },
colorMode: {
preference: 'system',
fallback: 'dark',
storage: 'cookie',
},
devtools: { devtools: {
enabled: true, enabled: true,
+22 -15
View File
@@ -8,37 +8,39 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"knip": "knip"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/cerebras": "^2.0.31", "@ai-sdk/cerebras": "^2.0.34",
"@ai-sdk/cohere": "^3.0.20", "@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/google": "^3.0.23", "@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.28", "@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/vue": "^3.0.78",
"@daveyplate/better-auth-triplit": "^0.2.2", "@daveyplate/better-auth-triplit": "^0.2.2",
"@iconify-json/mynaui": "^1.2.17", "@iconify-json/mynaui": "^1.2.17",
"@nuxt/fonts": "0.13.0",
"@nuxt/hints": "1.0.0-alpha.5", "@nuxt/hints": "1.0.0-alpha.5",
"@nuxt/icon": "2.2.0", "@nuxt/icon": "2.2.0",
"@nuxtjs/color-mode": "4.0.0", "@openrouter/ai-sdk-provider": "^2.2.3",
"@openrouter/ai-sdk-provider": "^2.1.1", "@sentry/nuxt": "^10.39.0",
"@sentry/nuxt": "^10",
"@triplit/client": "^1.0.50", "@triplit/client": "^1.0.50",
"@triplit/server": "^1.1.8",
"@types/big.js": "^6.2.2", "@types/big.js": "^6.2.2",
"ai": "^6.0.78", "@vue-macros/nuxt": "^3.1.2",
"ai-sdk-ollama": "^3.5.0", "ai": "^6.0.89",
"ai-sdk-ollama": "^3.7.1",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
"big.js": "^7.0.1", "big.js": "^7.0.1",
"dotenv": "^17.2.4", "comlink": "^4.4.2",
"glob": "^13.0.1", "glob": "^13.0.5",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"nuxt": "4.2.2", "nuxt": "4.2.2",
"nuxt-shiki": "0.3.2",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"shiki": "^3.22.0",
"triplit-nuxt": "0.3.1-prerelease.5", "triplit-nuxt": "0.3.1-prerelease.5",
"unified": "^11.0.5", "unified": "^11.0.5",
"vue": "^3.5.28", "vue": "^3.5.28",
@@ -49,10 +51,14 @@
"devDependencies": { "devDependencies": {
"@iconify-json/logos": "^1.2.10", "@iconify-json/logos": "^1.2.10",
"@iconify-json/svg-spinners": "^1.2.4", "@iconify-json/svg-spinners": "^1.2.4",
"@iconify-json/tabler": "^1.2.26",
"@triplit/cli": "^1.0.61", "@triplit/cli": "^1.0.61",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@unocss/nuxt": "^66.6.0", "@unocss/nuxt": "^66.6.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"knip": "^5.84.1",
"typescript": "^5.9.3",
"unocss": "^66.6.0" "unocss": "^66.6.0"
}, },
"trustedDependencies": [ "trustedDependencies": [
@@ -66,6 +72,7 @@
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch" "@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch"
}, },
"overrides": { "overrides": {
"@vercel/nft": "^0.27.4" "@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 diff --git a/dist/db.js b/dist/db.js
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..9c4d3060894285218d2d1f961930b5dad52d7bc5 100644 index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..0ce27b58838db11fd51fcf873a182d528d5823fb 100644
--- a/dist/db.js --- a/dist/db.js
+++ b/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 // TODO call the listeners in the entity store
// Trigger subscription updates // Trigger subscription updates
await this.ivm.bufferChanges(changes); await this.ivm.bufferChanges(changes);
@@ -13,3 +82,104 @@ index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..9c4d3060894285218d2d1f961930b5da
return output; return output;
} }
async applyChanges(changes, options) { 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!); const success = cancelPendingGeneration(generationId!);
if (!success) {
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!)); const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
if (generation !== null && generation.status === 'pending') { if (generation !== null && generation.status === 'pending') {
await httpClient.update('generations', generationId!, { await httpClient.update('generations', generationId!, {
status: 'cancelled', status: 'cancelled',
}); });
return;
} }
if (!success) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: 'Generation not found or already completed', message: 'Generation not found or already completed',
}); });
} }
return; return 'ok';
}); });
+29 -5
View File
@@ -9,7 +9,8 @@ import { httpClient } from '~~/server/lib/triplit';
import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations'; import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations';
import type { schema } from '~~/triplit/schema'; import type { schema } from '~~/triplit/schema';
import { spawn } from 'child_process'; 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); 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 generationId = nanoid();
const message = await httpClient.insert('messages', { const message = await httpClient.insert('messages', {
@@ -98,14 +118,20 @@ export default defineEventHandler(async (event) => {
let logMessage: ((message: string) => void) | undefined; let logMessage: ((message: string) => void) | undefined;
if (process.env.GENERATION_DEBUG) { if (process.env.GENERATION_DEBUG) {
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'); logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
logMessage = (message: string) => { logMessage = (message: string) => {
logFile!.write(message + '\n'); logFile!.write(message + '\n');
}; };
} else {
console.warn('Generation debug logging is enabled but GENERATION_DEBUG is not set');
}
} }
event.waitUntil( 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 { return {
@@ -755,8 +781,6 @@ async function generateResponse(
status: 'failed', status: 'failed',
error, error,
}); });
throw new Error(error);
} break; } break;
case 'finish': { case 'finish': {
let tps; let tps;
+232 -60
View File
@@ -1,7 +1,13 @@
import { type Entity } from '@triplit/client';
import Big from 'big.js';
import * as z from 'zod'; import * as z from 'zod';
import { providerBaseUrls, SupportedModalities } from '~/types/model'; import { SupportedModalities } from '~/types/model';
import { Providers } from '~/types/model'; import { Providers } from '~/types/model';
import { httpClient } from '~~/server/lib/triplit'; 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) => { export default defineEventHandler(async (event) => {
await protectRoute(event); await protectRoute(event);
@@ -32,50 +38,79 @@ export default defineEventHandler(async (event) => {
}); });
} }
let baseUrl; console.log("apiKey", result.data.providerApiKey);
let fetchUrl;
let headers; const [providerModelsRes, modelsDevRes] = await Promise.all([
switch (provider.type) { fetchProviderModels(provider, result.data.providerApiKey),
case 'cohere': getModelsDevData(),
case 'cerebras': ]);
if (!result.data.providerApiKey) { if (!providerModelsRes.ok) {
switch (providerModelsRes.error) {
case ProviderFetchError.NoProviderApiKey: {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: `${provider.type} provider requires an API key`, message: `${provider.type} provider requires an API key`,
}); });
} }
case 'google': case ProviderFetchError.NoProviderBaseUrl: {
case 'openrouter':
baseUrl = !!provider.config.apiProxyUrl ? provider.config.apiProxyUrl : providerBaseUrls[provider.type];
baseUrl = baseUrl.replace(/\/$/, '');
if (baseUrl === '') {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: 'Invalid provider URL', 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',
});
} }
baseUrl = provider.config.apiProxyUrl; }
baseUrl = baseUrl.replace(/\/$/, '');
fetchUrl = `${baseUrl}/api/tags`; console.log("providerModelsRes.data", providerModelsRes.data);
break;
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);
}
case GatewayFetchError.NoProviderBaseUrl: {
// throw createError({
// statusCode: 400,
// message: 'Invalid provider URL',
// });
return Err(ProviderFetchError.NoProviderBaseUrl);
}
}
}
const { endpoint: { baseURL, modelsEndpoint, headers } } = providerDetails.data;
switch (provider.type) {
case 'longcat': case 'longcat':
// longcat doesn't have a model list endpoint so we just hardcode them here, // 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: // 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 // en (approx): If we add an interface like this in the future, we will promptly update you
return { return Ok({
data: {
models: [ models: [
{ {
id: "LongCat-Flash-Chat", id: "LongCat-Flash-Chat",
@@ -117,24 +152,16 @@ export default defineEventHandler(async (event) => {
contextWindow: 320_000, contextWindow: 320_000,
} }
} }
] ],
}; },
} baseURL
});
if (provider.type === 'google') {
headers = {
'x-goog-api-key': `${result.data.providerApiKey}`
}
} else {
headers = {
'Authorization': `Bearer ${result.data.providerApiKey}`
}
} }
let res; let res;
let data; let data;
try { try {
res = await fetch(fetchUrl, { res = await fetch(`${baseURL}${modelsEndpoint}`, {
method: 'GET', method: 'GET',
headers headers
}); });
@@ -154,16 +181,89 @@ export default defineEventHandler(async (event) => {
}); });
} }
return { return Ok({ data, baseURL });
models: await normalizeResponse(data, provider.type, 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) { switch (provider) {
case 'cerebras': { case 'cerebras': {
console.log(response); console.log("response.data", response.data);
return response.data.map((model: any) => ({ id: model.id, releasedAt: model.created })); 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': { case 'openrouter': {
const models = []; 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({ models.push({
id: model.id as string, id: model.id as string,
name: model.name as string, name: model.name as string,
pricing: model.pricing, cost: pricing,
attributes: { 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)), 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)), outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
capabilities: Array.from(capabilities), capabilities: Array.from(capabilities),
contextWindow: model.context_length, contextWindow: model.context_length,
supported_parameters: model.supported_parameters, 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); 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, { models.set(model.name, {
...modelData,
id: model.name, id: model.name,
name: model.name, name: model.name,
attributes: { attributes: {
inputModalities: Array.from(inputModalities), ...modelData.attributes,
capabilities: Array.from(capabilities), inputModalities: mergeSets(
inputModalities,
new Set(modelData.attributes?.inputModalities || [])
),
capabilities: mergeSets(
capabilities,
new Set(modelData.attributes?.capabilities || [])
),
contextWindow, contextWindow,
} }
}); });
@@ -272,12 +437,10 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return Array.from(models.values()); return Array.from(models.values());
} }
case 'google': { case 'google': {
console.log(response); return response.models.map((model: any) => ({ ...getModelData(model.name.replace('models/', ''), provider, modelsDevData), id: model.name.replace('models/', ''), name: model.displayName }));
return response.models.map((model: any) => ({ id: model.name.replace('models/', ''), name: model.displayName }));
} }
case 'longcat': { case 'longcat': {
console.log(response); return response.models;
return response.models.map((model: any) => ({ id: model.name, name: model.name }));
} }
case 'cohere': { case 'cohere': {
const models = []; const models = [];
@@ -299,13 +462,22 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
} }
} }
const modelData = getModelData(model.name, provider, modelsDevData);
models.push({ models.push({
...modelData,
id: model.name, id: model.name,
name: model.name,
attributes: { attributes: {
contextWindow: model.context_length, ...modelData.attributes,
inputModalities: Array.from(inputModalities), inputModalities: mergeSets(
capabilities: Array.from(capabilities), 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 { cancelPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert'; import { assert } from '~~/utils/assert';
@@ -7,7 +8,15 @@ export default defineEventHandler(async (event) => {
const { renameId } = event.context.params!; const { renameId } = event.context.params!;
assert(renameId); 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 { return {
success: true, success: true,
}; };
+28 -4
View File
@@ -4,8 +4,9 @@ import { renamePrompt } from '~~/prompts';
import { schema } from '~~/triplit/schema'; import { schema } from '~~/triplit/schema';
import { type Entity } from '@triplit/client'; import { type Entity } from '@triplit/client';
import { generateText } from 'ai'; 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 { addPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await protectRoute(event); await protectRoute(event);
@@ -38,10 +39,31 @@ export default defineEventHandler(async (event) => {
message: 'Invalid model', message: 'Invalid model',
}); });
} }
assert(model.provider !== null, 'Invalid model provider');
const { gateway, textTransformer } = await getGateway(model.provider!, model, providerApiKey); const providerDetails = await getProviderDetails(model.provider, providerApiKey, model);
const [renameId, abortController] = addPendingRename(); if (!providerDetails.ok) {
event.waitUntil(autoRename(topicId, abortController, { gateway, model }, textTransformer, prompt)); 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 { return {
success: true, success: true,
@@ -51,6 +73,7 @@ export default defineEventHandler(async (event) => {
const autoRename = async ( const autoRename = async (
topicId: string, topicId: string,
renameId: string,
abortController: AbortController, abortController: AbortController,
model: { model: {
gateway: ModelGateway, gateway: ModelGateway,
@@ -86,6 +109,7 @@ const autoRename = async (
} catch (error) { } catch (error) {
console.error('Failed to auto-rename:', error); console.error('Failed to auto-rename:', error);
} finally { } finally {
completeRename(renameId);
await httpClient.update('topics', topicId, { await httpClient.update('topics', topicId, {
renaming: false renaming: false
}); });
+1 -1
View File
@@ -3,6 +3,6 @@ import { schema } from '#triplit/schema';
export const httpClient = new HttpClient({ export const httpClient = new HttpClient({
schema, 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, 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.`); //
});
});
+80 -36
View File
@@ -9,6 +9,7 @@ import { schema } from "~~/triplit/schema";
import { type StreamTextTransform } from "ai"; import { type StreamTextTransform } from "ai";
import { transformCerebrasReasoningStream } from "./cerebras"; import { transformCerebrasReasoningStream } from "./cerebras";
import { providerBaseUrls } from "~/types/model"; import { providerBaseUrls } from "~/types/model";
import { type Result, Err, Ok } from "~~/types/result";
// import { createLongcatTransformer } from "./longcat"; // import { createLongcatTransformer } from "./longcat";
export type ModelGateway = OpenRouterProvider | OllamaProvider | CerebrasProvider | GoogleGenerativeAIProvider | OpenAICompatibleProvider | CohereProvider; export type ModelGateway = OpenRouterProvider | OllamaProvider | CerebrasProvider | GoogleGenerativeAIProvider | OpenAICompatibleProvider | CohereProvider;
@@ -19,90 +20,133 @@ export interface Gateway {
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined; 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> { export interface Provider {
let gateway: ModelGateway; gateway: Gateway | null;
let streamTransformer = undefined; endpoint: {
let textTransformer = undefined; 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 baseURL = undefined;
let modelsEndpoint = undefined;
let headers: Record<string, string> = {};
if (provider.config.apiProxyUrl && provider.config.apiProxyUrl.trim() !== '') { if (provider.config.apiProxyUrl && provider.config.apiProxyUrl.trim() !== '') {
baseURL = provider.config.apiProxyUrl; baseURL = provider.config.apiProxyUrl;
} else {
baseURL = providerBaseUrls[provider.type];
} }
baseURL = baseURL.replace(/\/$/, '');
switch (provider.type) { switch (provider.type) {
case 'openrouter': { case 'openrouter': {
if (providerApiKey === undefined) { if (providerApiKey === undefined) {
throw createError({ return Err(GatewayFetchError.NoProviderApiKey);
statusCode: 400,
message: 'OpenRouter provider requires an API key',
});
} }
gateway = createOpenRouter({ gateway.gateway = createOpenRouter({
apiKey: providerApiKey, apiKey: providerApiKey,
headers: { headers: {
'HTTP-Referer': 'https://localhost:3000', 'HTTP-Referer': 'https://localhost:3000',
'X-Title': 'Veridian', 'X-Title': 'Veridian',
}, },
}); });
break; headers['Authorization'] = `Bearer ${providerApiKey}`
} modelsEndpoint = `/models`;
} break;
case 'ollama': { case 'ollama': {
if (baseURL === undefined) { if (baseURL === undefined) {
throw createError({ return Err(GatewayFetchError.NoProviderBaseUrl);
statusCode: 400,
message: 'Ollama provider requires an API proxy URL',
});
} }
if (providerApiKey !== undefined) {
headers['Authorization'] = `Bearer ${providerApiKey}`
}
modelsEndpoint = `/api/tags`;
if (model !== undefined) {
const innerGateway = createOllama({ const innerGateway = createOllama({
apiKey: providerApiKey, apiKey: providerApiKey,
baseURL, baseURL,
}) })
gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider; gateway.gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider;
break;
} }
} break;
case 'cerebras': { case 'cerebras': {
gateway = createCerebras({ if (providerApiKey === undefined) {
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway.gateway = createCerebras({
apiKey: providerApiKey, apiKey: providerApiKey,
baseURL, baseURL,
}) })
streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>; gateway.streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>;
textTransformer = (text: string) => { gateway.textTransformer = (text: string) => {
return text.split('</think>').at(-1)!.trim() return text.split('</think>').at(-1)!.trim()
}; };
break; headers['Authorization'] = `Bearer ${providerApiKey}`
} modelsEndpoint = `/models`;
} break;
case 'google': { case 'google': {
gateway = createGoogleGenerativeAI({ if (providerApiKey === undefined) {
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway.gateway = createGoogleGenerativeAI({
apiKey: providerApiKey, apiKey: providerApiKey,
baseURL, baseURL,
}) });
break; headers['x-goog-api-key'] = `${providerApiKey}`
} modelsEndpoint = `/models`;
} break;
case 'longcat': { case 'longcat': {
gateway = createOpenAICompatible({ if (providerApiKey === undefined) {
return Err(GatewayFetchError.NoProviderApiKey);
}
gateway.gateway = createOpenAICompatible({
name: 'LongCat', name: 'LongCat',
apiKey: providerApiKey, apiKey: providerApiKey,
baseURL: baseURL ?? providerBaseUrls[provider.type], baseURL: baseURL ?? providerBaseUrls[provider.type],
includeUsage: true, includeUsage: true,
}) })
headers['Authorization'] = `Bearer ${providerApiKey}`
modelsEndpoint = null;
// streamTransformer = createLongcatTransformer() as StreamTextTransform<{}>; // streamTransformer = createLongcatTransformer() as StreamTextTransform<{}>;
} break; } break;
case 'cohere': { case 'cohere': {
gateway = createCohere({ if (providerApiKey === undefined) {
apiKey: providerApiKey, return Err(GatewayFetchError.NoProviderApiKey);
baseURL,
})
}
} }
return { gateway.gateway = createCohere({
apiKey: providerApiKey,
baseURL,
});
headers['Authorization'] = `Bearer ${providerApiKey}`
modelsEndpoint = `/models`;
} break;
}
return Ok({
gateway, gateway,
streamTransformer, endpoint: {
textTransformer, 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: { export function createLongcatTransformer<TOOLS extends ToolSet>(): (options: {
tools: TOOLS; 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 pendingRenames: Map<string, PendingRename> = new Map();
const controller = pendingRenames.get(renameId);
if (controller) { export const cancelPendingRename = (renameId: string): [boolean, PendingRename?] => {
controller.abort(); const pendingRename = pendingRenames.get(renameId);
if (pendingRename?.abortController) {
pendingRename.abortController.abort();
pendingRenames.delete(renameId); pendingRenames.delete(renameId);
return true; return [true, pendingRename];
} }
return false;
return [false, undefined];
}; };
export const completeRename = (renameId: string) => { 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 id = crypto.randomUUID();
const controller = new AbortController(); const abortController = new AbortController();
const pendingRename = { topicId, abortController };
pendingRenames.set(id, controller); pendingRenames.set(id, pendingRename);
return [id, controller]; return [id, pendingRename];
}; };
+7
View File
@@ -17,6 +17,13 @@ export const schema = S.Collections({
systemAssistants: S.Record({ systemAssistants: S.Record({
rename: systemAssistantRecord 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: { permissions: {
authenticated: { authenticated: {
+7 -1
View File
@@ -2,7 +2,13 @@
// https://nuxt.com/docs/guide/concepts/typescript // https://nuxt.com/docs/guide/concepts/typescript
"files": [], "files": [],
"compilerOptions": { "compilerOptions": {
"noFallthroughCasesInSwitch": true "target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"noFallthroughCasesInSwitch": true,
}, },
"references": [ "references": [
{ {
+11
View File
@@ -19,3 +19,14 @@ export const Err = <E>(error: E): Err<E> => ({
}); });
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: [ presets: [
presetMini(), presetMini(),
presetWebFonts({
provider: 'fontsource',
fonts: {
sans: 'Geist',
mono: 'JetBrains Mono',
},
}),
], ],
}); });