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:
+17
-15
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
// import 'katex/dist/katex.min.css'
|
||||
import '~/assets/css/reset.css';
|
||||
import '~/assets/css/base.css';
|
||||
|
||||
@@ -9,7 +11,14 @@ if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
style: `--color-accent: var(--accent-${accent.value}, var(--accent-violet)); --color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover)); --neutral-dark: var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark)); --neutral-light: var(--neutral-${neutral.value}-light, var(--neutral-zinc-light)); --accent-hinting: ${hinting.value}%; --base-hinting: calc(100% - var(--accent-hinting));`,
|
||||
style: `
|
||||
--neutral-100: var(--palette-${neutral.value}-100);
|
||||
--neutral-200: var(--palette-${neutral.value}-200);
|
||||
--neutral-300: var(--palette-${neutral.value}-300);
|
||||
--color-accent: var(--accent-${accent.value}, var(--accent-violet));
|
||||
--color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover));
|
||||
--accent-hinting: ${hinting.value}%;
|
||||
--base-hinting: calc(100% - var(--accent-hinting));`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,23 +35,16 @@ if (import.meta.client) {
|
||||
'--color-accent-hover',
|
||||
`var(--accent-${accent.value}-hover, var(--accent-violet-hover))`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--neutral-dark',
|
||||
`var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--neutral-light',
|
||||
`var(--neutral-${neutral.value}-light, var(--neutral-zinc-light))`,
|
||||
);
|
||||
// document.documentElement.style.setProperty(
|
||||
// '--color-neutral',
|
||||
// `var(--neutral-${neutral.value}, var(--neutral-zinc))`,
|
||||
// );
|
||||
document.documentElement.style.setProperty('--neutral-100', `var(--palette-${neutral.value}-100)`);
|
||||
document.documentElement.style.setProperty('--neutral-200', `var(--palette-${neutral.value}-200)`);
|
||||
document.documentElement.style.setProperty('--neutral-300', `var(--palette-${neutral.value}-300)`);
|
||||
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`);
|
||||
});
|
||||
}
|
||||
|
||||
useHead({
|
||||
bodyAttrs: {
|
||||
class: 'font-sans'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+344
-21
@@ -1,9 +1,7 @@
|
||||
:root {
|
||||
--font-sans: system-ui, sans-serif;
|
||||
--sidebar-width: 400px;
|
||||
--spacing: 0.25rem;
|
||||
|
||||
--color-accent-text: #000;
|
||||
--color-accent-text: #232625;
|
||||
|
||||
--reasoning-accent: #a62bcb;
|
||||
|
||||
@@ -53,18 +51,68 @@
|
||||
--accent-magenta: #ec4899;
|
||||
--accent-magenta-hover: #db2777;
|
||||
|
||||
/* Neutral Base Colors - different gray variations */
|
||||
|
||||
/* zinc - deep gray-blue */
|
||||
--neutral-zinc-dark: #171619;
|
||||
--neutral-zinc-light: #fbfafd;
|
||||
|
||||
/* charcoal - deep black-gray */
|
||||
--neutral-charcoal-dark: #0a0a0a;
|
||||
--neutral-charcoal-light: #fefdff;
|
||||
--bg-base: color-mix(in srgb, var(--neutral-100), var(--color-accent) var(--accent-hinting));
|
||||
--bg-surface: color-mix(in srgb, var(--neutral-200), var(--color-accent) var(--accent-hinting));
|
||||
--bg-container: color-mix(in srgb, var(--neutral-300), var(--color-accent) var(--accent-hinting));
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--palette-zinc-100: #09080A;
|
||||
--palette-zinc-200: #1a191b;
|
||||
--palette-zinc-300: #2e2d2f;
|
||||
--palette-slate-100: #0f171c;
|
||||
--palette-slate-200: #1e252b;
|
||||
--palette-slate-300: #333b44;
|
||||
--palette-obsidian-100: #050504;
|
||||
--palette-obsidian-200: #0f0f0e;
|
||||
--palette-obsidian-300: #1e1f1f;
|
||||
|
||||
--text-primary: color-mix(in srgb, #fafafa, var(--color-accent) var(--accent-hinting));
|
||||
/* Selected topics */
|
||||
--text-secondary: color-mix(in srgb, #a1a1aa, var(--color-accent) var(--accent-hinting));
|
||||
/* Selected topics */
|
||||
--text-tertiary: color-mix(in srgb, #71717a, var(--color-accent) var(--accent-hinting));
|
||||
/* Tokens/s, Info */
|
||||
--text-dim: color-mix(in srgb, #52525b, var(--color-accent) var(--accent-hinting));
|
||||
/* Unselected/Disabled */
|
||||
|
||||
--color-hover: color-mix(in srgb, rgba(255, 255, 255, 0.08), var(--color-accent) var(--accent-hinting));
|
||||
--color-active: color-mix(in srgb, rgba(255, 255, 255, 0.12), var(--color-accent) var(--accent-hinting));
|
||||
|
||||
--color-border: color-mix(in srgb, rgba(255, 255, 255, 0.1), var(--color-accent) var(--accent-hinting));
|
||||
--color-border-active: color-mix(in srgb, rgba(255, 255, 255, 0.16), var(--color-accent) var(--accent-hinting));
|
||||
}
|
||||
|
||||
:root.light {
|
||||
/* --palette-zinc-100: #f4f4f5; --palette-zinc-200: #e4e4e7; --palette-zinc-300: #d4d4d8;
|
||||
--palette-slate-100: #f1f5f9; --palette-slate-200: #e2e8f0; --palette-slate-300: #cbd5e1;
|
||||
--palette-obsidian-100: #fafafa; --palette-obsidian-200: #f5f5f5; --palette-obsidian-300: #e5e5e5; */
|
||||
--palette-zinc-100: #d4d4d8;
|
||||
--palette-zinc-200: #e4e4e7;
|
||||
--palette-zinc-300: #f4f4f5;
|
||||
--palette-slate-100: #cbd5e1;
|
||||
--palette-slate-200: #e2e8f0;
|
||||
--palette-slate-300: #f1f5f9;
|
||||
--palette-obsidian-100: #e5e5e5;
|
||||
--palette-obsidian-200: #ededed;
|
||||
--palette-obsidian-300: #fcfcfc;
|
||||
|
||||
--text-primary: color-mix(in srgb, #09090b, var(--color-accent) var(--accent-hinting));
|
||||
--text-secondary: color-mix(in srgb, #52525b, var(--color-accent) var(--accent-hinting));
|
||||
--text-tertiary: color-mix(in srgb, #71717a, var(--color-accent) var(--accent-hinting));
|
||||
--text-dim: color-mix(in srgb, #a1a1aa, var(--color-accent) var(--accent-hinting));
|
||||
|
||||
--color-hover: color-mix(in srgb, rgba(0, 0, 0, 0.08), var(--color-accent) var(--accent-hinting));
|
||||
--color-active: color-mix(in srgb, rgba(0, 0, 0, 0.12), var(--color-accent) var(--accent-hinting));
|
||||
|
||||
--color-border: color-mix(in srgb, rgba(0, 0, 0, 0.08), var(--color-accent) var(--accent-hinting));
|
||||
--color-border-active: color-mix(in srgb, rgba(0, 0, 0, 0.14), var(--color-accent) var(--accent-hinting));
|
||||
}
|
||||
|
||||
/* :root.dark {
|
||||
--neutral-zinc: color-mix(in srgb, var(--base-hinting) #171619, var(--accent-hinting) var(--color-accent));
|
||||
--neutral-charcoal: color-mix(in srgb, var(--base-hinting) #0a0a0a, var(--accent-hinting) var(--color-accent));
|
||||
|
||||
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
|
||||
--color-highlight-low: color-mix(in srgb,
|
||||
var(--base-hinting) rgba(255, 255, 255, 0.05),
|
||||
@@ -79,13 +127,13 @@
|
||||
--color-muted: color-mix(in srgb, var(--base-hinting) #a1a1a0, var(--accent-hinting) var(--color-accent));
|
||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #d1d1d0, var(--accent-hinting) var(--color-accent));
|
||||
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
|
||||
--color-neutral: color-mix(in srgb,
|
||||
var(--base-hinting) var(--neutral-dark),
|
||||
var(--accent-hinting) var(--color-accent));
|
||||
--color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--neutral-zinc: color-mix(in srgb, var(--base-hinting) #fbfafd, var(--accent-hinting) var(--color-accent));
|
||||
--neutral-charcoal: color-mix(in srgb, var(--base-hinting) #fefdff, var(--accent-hinting) var(--color-accent));
|
||||
|
||||
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
|
||||
--color-highlight-low: color-mix(in srgb,
|
||||
var(--base-hinting) rgba(0, 0, 0, 0.06),
|
||||
@@ -100,18 +148,23 @@
|
||||
--color-muted: color-mix(in srgb, var(--base-hinting) #6b7270, var(--accent-hinting) var(--color-accent));
|
||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #9ba2a0, var(--accent-hinting) var(--color-accent));
|
||||
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
|
||||
--color-neutral: color-mix(in srgb,
|
||||
var(--base-hinting) var(--neutral-light),
|
||||
var(--accent-hinting) var(--color-accent));
|
||||
--color-reasoning: color-mix(in srgb, var(--base-hinting) #99979c, var(--accent-hinting) var(--color-accent));
|
||||
}
|
||||
} */
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: "Geist", ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans",
|
||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: var(--color-base);
|
||||
color: var(--color-text);
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
@@ -154,4 +207,274 @@ button.accent:hover {
|
||||
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.has-obtrusive-scrollbars .chat-scroll-container {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.has-obtrusive-scrollbars .chatPane {
|
||||
margin-right: calc(-1 * var(--thin-scrollbar-width));
|
||||
width: calc(100% + var(--thin-scrollbar-width));
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-rotate {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.vl-toggle-switch[aria-disabled="true"] div {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] div {
|
||||
transform-origin: center right;
|
||||
transform: translateX(calc(2.5em - 1em - 0.5rem));
|
||||
}
|
||||
|
||||
.vl-toggle-switch:active div {
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"]:active div {
|
||||
transform: translateX(calc(2.5em - 1.3em - 0.5rem));
|
||||
}
|
||||
|
||||
.reasoning-contaizner.middle {
|
||||
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.top {
|
||||
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.bottom {
|
||||
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
||||
}
|
||||
|
||||
/* Markdown renderer */
|
||||
article>* {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:first-child {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:last-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
article>*:only-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article math {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
article mtd {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
article hr {
|
||||
border: 1px solid var(--text-dim);
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
article li {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
article ul {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
article ul>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
article ul>li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.5rem;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background-color: var(--text-tertiary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
article ul>li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 23px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--text-dim);
|
||||
}
|
||||
|
||||
article ul>li:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
article ol {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
counter-reset: ordered-list-counter var(--start-value, 0);
|
||||
}
|
||||
|
||||
article ol[start] {
|
||||
--start-value: calc(attr(start type(<number>)) - 1);
|
||||
}
|
||||
|
||||
article ol>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
counter-increment: ordered-list-counter;
|
||||
}
|
||||
|
||||
article ol>li::before {
|
||||
content: counter(ordered-list-counter) ".";
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
color: var(--color-muted);
|
||||
font-weight: 500;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
html.dark .shiki,
|
||||
html.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
article ol:only-child,
|
||||
article ul:only-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
article code:not(pre code) {
|
||||
background-color: var(--color-hover);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
article blockquote {
|
||||
color: var(--text-secondary);
|
||||
border-left: 4px solid var(--text-tertiary);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
|
||||
article table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
article table thead tr {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
article table th {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
article table td {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
article table tbody tr {
|
||||
background-color: var(--color-hover);
|
||||
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
|
||||
}
|
||||
|
||||
article table tbody tr:nth-of-type(even) {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
article table tbody tr:hover {
|
||||
background-color: var(--color-active);
|
||||
}
|
||||
|
||||
|
||||
article h1,
|
||||
article h2,
|
||||
article h3,
|
||||
article h4,
|
||||
article h5,
|
||||
article h6 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
article .checkbox {
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
/* end markdown renderer */
|
||||
|
||||
.reasoning-contaizner.middle {
|
||||
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.top {
|
||||
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.bottom {
|
||||
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch, computed } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import { schema } from '#triplit/schema';
|
||||
|
||||
const { allModels } = await useModels();
|
||||
|
||||
const inputHeight: Ref<string> = ref('auto');
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
let tempInput = '';
|
||||
const inputValue = ref('');
|
||||
const inputValue = defineModel<string>({ required: false, default: '' });
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,10 +23,8 @@ const props = defineProps<{
|
||||
providers?: ProviderWithModels[];
|
||||
}>();
|
||||
|
||||
// Model selection state
|
||||
const selectedModel = ref<ModelWithProvider | null>(null);
|
||||
|
||||
// Initialize model selection based on agent's defaultModelId or first available
|
||||
const initializeModel = () => {
|
||||
if (selectedModel.value) return;
|
||||
if (!props.providers || !props.agent) return;
|
||||
@@ -42,12 +41,10 @@ const initializeModel = () => {
|
||||
// Fall back to first available model
|
||||
if (allModels.value.length > 0) {
|
||||
selectedModel.value = allModels.value[0]!;
|
||||
// Update agent's default model
|
||||
updateAgentDefaultModel(selectedModel.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Update agent's default model in Triplit
|
||||
const updateAgentDefaultModel = async (modelId: string) => {
|
||||
if (!props.agent) return;
|
||||
try {
|
||||
@@ -103,8 +100,6 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
|
||||
inputValue.value =
|
||||
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
|
||||
await nextTick();
|
||||
handleInput();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -112,11 +107,12 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
watch(inputValue, async () => {
|
||||
const textarea = inputRef.value;
|
||||
if (!textarea) return;
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
inputHeight.value = 'auto';
|
||||
await nextTick();
|
||||
|
||||
const lineHeight = 24;
|
||||
const maxLines = 10;
|
||||
@@ -125,11 +121,11 @@ const handleInput = () => {
|
||||
const newHeight = textarea.scrollHeight;
|
||||
|
||||
if (newHeight > maxHeight) {
|
||||
textarea.style.height = `${maxHeight}px`;
|
||||
inputHeight.value = `${maxHeight}px`;
|
||||
} else {
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
inputHeight.value = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
}, { immediate: true });
|
||||
|
||||
let hasCommandKey = false;
|
||||
if (import.meta.server) {
|
||||
@@ -145,40 +141,34 @@ onBeforeMount(() => {
|
||||
|
||||
onMounted(() => {
|
||||
inputValue.value = tempInput;
|
||||
nextTick(() => {
|
||||
handleInput();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['w-full flex max-h-full', $attrs.class]">
|
||||
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--color-input)]
|
||||
border-[var(--color-highlight)] focus-within:border-[var(--color-highlight-high)]">
|
||||
<!-- Text Input -->
|
||||
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
|
||||
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
|
||||
<div class="flex-1 min-w-0 max-h-full">
|
||||
<!-- Grammarly literally breaks everything, go fuck yourself -->
|
||||
<!-- It is absolutely paramount that the closing tag for the textare has ZERO whitespace between the end of the textarea opening tag, otherwise there will be hydration errors -->
|
||||
<textarea data-gramm="false" id="chat" v-model="inputValue" ref="inputRef"
|
||||
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
|
||||
@keydown="handleKeyDown" @input="handleInput"
|
||||
class="[scrollbar-width:none] w-full bg-transparent text-[var(--color-text)] resize-none outline-none text-[15px] leading-6 min-h-0 overflow-y-auto">
|
||||
</textarea>
|
||||
@keydown="handleKeyDown" :style="{ height: inputHeight }"
|
||||
class="[scrollbar-width:none] w-full bg-transparent resize-none text-[0.95em] placeholder:text-[var(--text-tertiary)]"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
|
||||
<div class="flex-1">
|
||||
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers">
|
||||
</ModelSelector>
|
||||
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers" />
|
||||
</div>
|
||||
<!-- Send/Stop Button -->
|
||||
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
|
||||
:class="[
|
||||
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||
inputValue.trim() && !loading
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
|
||||
: 'text-[var(--color-highlight-high)]',
|
||||
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]',
|
||||
: 'text-[var(--text-dim)]',
|
||||
loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]',
|
||||
]">
|
||||
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
|
||||
<Icon v-else name="mynaui:send-solid" class="text-5" />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isOpen: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid transition-all duration-250 ease-in-out transform-origin-top-center" :class="[
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100 scale-100' : 'grid-rows-[0fr] opacity-0 scale-95',
|
||||
]">
|
||||
<div class="overflow-hidden">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+20
-86
@@ -1,97 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
<script setup>
|
||||
import { useDropdown } from '~/composables/useDropdown';
|
||||
const { dropdownState, closeDropdown } = useDropdown();
|
||||
const menuRef = ref(null);
|
||||
|
||||
interface Props {
|
||||
items?: DropdownItem[];
|
||||
placement?: 'right' | 'left' | 'center';
|
||||
verticality?: 'asscending' | 'descending';
|
||||
width?: string;
|
||||
}
|
||||
const currentItems = computed(() => dropdownState.itemsFactory?.());
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placement: 'right',
|
||||
verticality: 'descending',
|
||||
width: 'auto',
|
||||
});
|
||||
|
||||
const emit = defineEmits<(e: 'select', item: DropdownItem) => void>();
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const isOpen = defineModel<boolean>({ required: false });
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const select = (item: DropdownItem) => {
|
||||
if (item.disabled || item.divider) return;
|
||||
emit('select', item);
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const placementClasses = computed(() => {
|
||||
let classes = '';
|
||||
|
||||
switch (props.placement) {
|
||||
case 'right':
|
||||
classes += 'right-0 ';
|
||||
break;
|
||||
case 'left':
|
||||
classes += 'left-0 ';
|
||||
break;
|
||||
case 'center':
|
||||
classes += 'left-1/2 -translate-x-1/2 ';
|
||||
break;
|
||||
default:
|
||||
classes += 'left-0 ';
|
||||
break;
|
||||
}
|
||||
|
||||
switch (props.verticality) {
|
||||
case 'asscending':
|
||||
classes += 'bottom-full mb-1.5';
|
||||
break;
|
||||
case 'descending':
|
||||
classes += 'top-full mt-1.5';
|
||||
break;
|
||||
}
|
||||
|
||||
return classes;
|
||||
});
|
||||
|
||||
useClickOutside(triggerRef, () => {
|
||||
isOpen.value = false;
|
||||
});
|
||||
useClickOutside(menuRef, closeDropdown);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="triggerRef" :class="$attrs.class">
|
||||
<slot name="trigger" :toggle="toggle" :is-open="isOpen"></slot>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||
<div v-if="isOpen" :class="[
|
||||
'absolute z-50 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-1.5 flex flex-col gap-1',
|
||||
placementClasses
|
||||
]" :style="{ width: width !== 'auto' ? width : undefined }">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<div v-if="item.divider" class="h-px bg-[var(--color-highlight)] my-1" />
|
||||
<button @click="select(item); item.onClick && item.onClick()" :disabled="item.disabled"
|
||||
class="bg-transparent w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left"
|
||||
:class="[
|
||||
item.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer'
|
||||
]">
|
||||
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="text-sm whitespace-nowrap">{{ item.label }}</span>
|
||||
<slot name="item-after" :item="item"></slot>
|
||||
</button>
|
||||
</template>
|
||||
<slot name="content" :toggle="toggle"></slot>
|
||||
<div v-if="dropdownState.open" ref="menuRef"
|
||||
:style="{ top: `${dropdownState.y}px`, left: `${dropdownState.x}px`, width: dropdownState.options?.width || 'auto', minWidth: dropdownState.options?.minWidth || 'auto', maxWidth: dropdownState.options?.maxWidth || 'auto', transform: `translate(${dropdownState.options?.placement === 'right' ? '-100%' : dropdownState.options?.placement === 'center' ? '-50%' : '0'}, ${dropdownState.options?.verticality === 'ascending' ? 'calc(-100% - 0.25rem)' : '0.25rem'})` }"
|
||||
class="flex flex-col gap-1 fixed z-[100] bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-lg p-1.5 shadow-xl">
|
||||
<!-- Render your items based on activeMenu.content -->
|
||||
<button v-for="item in currentItems" @click="item.onClick(); closeDropdown()" :disabled="item.disabled"
|
||||
class="disabled:opacity-50 disabled:cursor-not-allowed items-center gap-1 text-left px-3 py-1.5 text-sm hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
:class="{ 'text-red-600 hover:bg-red-600/20': item.danger, 'text-primary bg-[var(--color-hover)]': item.active }">
|
||||
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="text-sm whitespace-nowrap text-ellipsis max-w-full overflow-hidden">{{ item.label
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -14,7 +14,8 @@ const AVATAR_SCALE = 0.75;
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center"
|
||||
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
|
||||
<svg :fill="avatar ? 'currentColor' : ''" fill-rule="evenodd" :height="size" style="flex: none; line-height: 1;"
|
||||
<svg :fill="avatar || !color ? 'currentColor' : ''" fill-rule="evenodd" :height="size"
|
||||
style="flex: none; line-height: 1;"
|
||||
:style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
|
||||
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { RootContent } from 'hast';
|
||||
import { h, Text, computed } from 'vue';
|
||||
import MarkdownShikiHighlight from './ShikiHighlight.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -10,292 +10,42 @@ const props = defineProps<{
|
||||
|
||||
const { $remark } = useNuxtApp();
|
||||
|
||||
function splitMarkdown(markdown: string): string[] {
|
||||
const paragraphs: string[] = [];
|
||||
let currentParagraph = "";
|
||||
let isInCodeBlock = false;
|
||||
|
||||
const lines = markdown.split("\n");
|
||||
|
||||
for (let line of lines) {
|
||||
if (line.trim().startsWith("```")) {
|
||||
isInCodeBlock = !isInCodeBlock;
|
||||
}
|
||||
|
||||
if (line.trim() === "" && !isInCodeBlock) {
|
||||
if (currentParagraph.trim() !== "") {
|
||||
paragraphs.push(currentParagraph.trim());
|
||||
currentParagraph = "";
|
||||
}
|
||||
} else {
|
||||
currentParagraph += (currentParagraph === "" ? "" : "\n") + line;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentParagraph.trim() !== "") {
|
||||
paragraphs.push(currentParagraph.trim());
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
const partseAst = async (content: string) => {
|
||||
const mdast = $remark.parse(content);
|
||||
const hast = $remark.run(mdast);
|
||||
return hast;
|
||||
}
|
||||
|
||||
// SSR Initial Load
|
||||
const { data: hastParts } = await useAsyncData(`md-${props.id}`, async () => {
|
||||
return (await partseAst(props.content)).children;
|
||||
const ast = computed(() => {
|
||||
const mdast = $remark.parse(props.content);
|
||||
return $remark.runSync(mdast);
|
||||
});
|
||||
|
||||
let activeIdx = 0;
|
||||
let partIdx = [0];
|
||||
const renderNode = (node: any, index: number): any => {
|
||||
if (node.type === 'text' || node.type === 'raw') return h(Text, node.value);
|
||||
|
||||
if (import.meta.client && hastParts.value && hastParts.value.length > 0 && !props.finished) {
|
||||
const initialParts = splitMarkdown(props.content);
|
||||
if (node.type === 'element') {
|
||||
if (node.tagName === 'code') {
|
||||
const isBlock = node.position?.start.line !== node.position?.end.line;
|
||||
if (isBlock && node.children?.[0]?.type === 'text') {
|
||||
return h(MarkdownShikiHighlight, {
|
||||
key: `code-${index}`,
|
||||
code: node.children[0].value,
|
||||
language: node.properties?.className?.[0]?.replace('language-', '') || 'text'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
activeIdx = Math.max(0, initialParts.length - 1);
|
||||
const children = node.children?.map((child: any, i: number) => renderNode(child, i)) || [];
|
||||
|
||||
let currentOffset = 0;
|
||||
for (let i = 0; i < initialParts.length; i++) {
|
||||
partIdx[i] = currentOffset;
|
||||
|
||||
const tempAst = await partseAst(initialParts[i]!);
|
||||
currentOffset += tempAst.children.length;
|
||||
return h(
|
||||
node.tagName,
|
||||
{ ...node.properties, key: `${node.tagName}-${index}` },
|
||||
children
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
defineRender(() => {
|
||||
const children = ast.value?.children?.flatMap(renderNode) || [];
|
||||
|
||||
const parts = computed(() => {
|
||||
return splitMarkdown(props.content);
|
||||
})
|
||||
|
||||
watch(parts, async (newParts) => {
|
||||
if (!hastParts.value) hastParts.value = [];
|
||||
|
||||
while (activeIdx < newParts.length - 1) {
|
||||
const finalHast = await partseAst(newParts[activeIdx]!);
|
||||
|
||||
const base: RootContent[] = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = base.concat(finalHast.children);
|
||||
|
||||
partIdx[activeIdx + 1] = hastParts.value!.length;
|
||||
activeIdx++;
|
||||
}
|
||||
|
||||
const currentString = newParts[activeIdx];
|
||||
if (currentString !== undefined) {
|
||||
const latestHast = await partseAst(currentString);
|
||||
|
||||
const stableBase = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = stableBase.concat(latestHast.children);
|
||||
}
|
||||
return h('div', { class: 'prose-wrapper' }, [
|
||||
h('article', { class: 'markdown-body' }, children)
|
||||
]);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-wrapper">
|
||||
<article class="markdown-body">
|
||||
<template v-for="(node, index) in hastParts" :key="`${id}-${index}`">
|
||||
<MarkdownAstNode :node="node" v-memo="[node.type === 'text' ? node.value : node.data]" />
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
article>* {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:first-child {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:last-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
article>*:only-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid var(--color-highlight-high);
|
||||
}
|
||||
|
||||
li {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
ul>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
ul>li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.5rem;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background-color: var(--color-muted);
|
||||
border-radius: 50%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
ul>li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 23px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--color-highlight);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
ul>li:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
counter-reset: ordered-list-counter var(--start-value, 0);
|
||||
}
|
||||
|
||||
ol[start] {
|
||||
--start-value: calc(attr(start type(<number>)) - 1);
|
||||
}
|
||||
|
||||
ol>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
counter-increment: ordered-list-counter;
|
||||
}
|
||||
|
||||
ol>li::before {
|
||||
content: counter(ordered-list-counter) ".";
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
color: var(--color-muted);
|
||||
font-weight: 500;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
html.dark .shiki,
|
||||
html.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
/* Optional, if you also want font styles */
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
ol:only-child,
|
||||
ul:only-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
code:not(pre code) {
|
||||
background-color: var(--color-highlight);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color: var(--color-muted);
|
||||
border-left: 4px solid var(--color-highlight-high);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
margin: calc(var(--spacing) * 4) 0;
|
||||
}
|
||||
|
||||
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
background-color: var(--color-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
table thead tr {
|
||||
background-color: var(--color-highlight-high);
|
||||
}
|
||||
|
||||
table th {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
table tbody tr {
|
||||
background-color: var(--color-highlight);
|
||||
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
|
||||
}
|
||||
|
||||
table tbody tr:nth-of-type(even) {
|
||||
background-color: var(--color-highlight-low);
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
table tbody tr:hover {
|
||||
background-color: var(--color-highlight-high);
|
||||
}
|
||||
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
label>span {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: min-content;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import { type Grammar } from 'shiki';
|
||||
import { hashSync } from '~/utils/hash';
|
||||
const props = defineProps<{ code: string; lang: string }>();
|
||||
const props = defineProps<{ code: string; language: string }>();
|
||||
|
||||
const renderId = hashSync(props.code + props.lang);
|
||||
const codeBlockRef = ref<HTMLDivElement | null>(null);
|
||||
const codeHeight: Ref<string | number> = ref('auto');
|
||||
const start = Date.now();
|
||||
|
||||
const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
|
||||
const copied = ref(false);
|
||||
const collapsed = ref(false);
|
||||
|
||||
const { data: parsed } = useAsyncData(`shiki-${renderId}`, async () => {
|
||||
const { html, displayLang } = await parseCode();
|
||||
return { html, displayLang };
|
||||
});
|
||||
const { $shiki } = useNuxtApp();
|
||||
|
||||
const { data: parsed, clear } = await useAsyncData(`shiki-${renderId}`,
|
||||
() => parseCode(props.code, props.language.toLowerCase()),
|
||||
{
|
||||
watch: [() => props.code],
|
||||
dedupe: 'defer'
|
||||
}
|
||||
);
|
||||
|
||||
const lineNumberWidth = computed(() => {
|
||||
if (!parsed.value?.html) return 1;
|
||||
// Count newlines in the generated HTML or the source code
|
||||
@@ -19,25 +26,18 @@ const lineNumberWidth = computed(() => {
|
||||
return props.code.split('\n').length.toString().length;
|
||||
});
|
||||
|
||||
watch(() => props.code, async () => {
|
||||
|
||||
const { html: codeHtml } = await parseCode();
|
||||
parsed.value = { html: codeHtml, displayLang: parsed.value!.displayLang };
|
||||
});
|
||||
|
||||
async function parseCode() {
|
||||
const shiki = await getShikiHighlighter();
|
||||
let lang = props.lang.toLowerCase();
|
||||
async function parseCode(code: string, lang: string) {
|
||||
let displayLang = lang;
|
||||
|
||||
try {
|
||||
const shikiLang = shiki.getLanguage(lang);
|
||||
displayLang = shikiLang.name;
|
||||
let shikiLang = await $shiki.getLanguage(lang);
|
||||
|
||||
displayLang = (shikiLang as unknown as Grammar).name;
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
|
||||
const html = shiki.codeToHtml(props.code.trim(), {
|
||||
let html = await $shiki.codeToHtml(code.trim(), {
|
||||
lang,
|
||||
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
|
||||
});
|
||||
@@ -57,81 +57,47 @@ function copyCode() {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function collapseCode() {
|
||||
if (!codeBlockRef.value) return;
|
||||
|
||||
collapsed.value = !collapsed.value;
|
||||
if (collapsed.value) {
|
||||
codeHeight.value = codeBlockRef.value.scrollHeight;
|
||||
nextTick(() => {
|
||||
// since we are changing the height of an element, even though its
|
||||
// to its own height, we are triggering a reflow, which means that
|
||||
// if we didnt use requestAnimationFrame, the height would be set
|
||||
// to 0 before the reflow is complete, which would cause the reflow
|
||||
// to be ignored, and another one to be triggered with the new height
|
||||
// of zero, causing the codeblock to snap shut immediately rather than
|
||||
// animating. Not requestAnimationFrame because it doesnt work
|
||||
// on firefox, but setTimeout works on both chrome and firefox
|
||||
setTimeout(() => {
|
||||
codeHeight.value = 0;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
codeBlockRef.value!.addEventListener('transitionend', () => {
|
||||
if (collapsed.value) return;
|
||||
codeHeight.value = 'auto';
|
||||
}, { once: true })
|
||||
|
||||
const targetHeight = codeBlockRef.value.scrollHeight;
|
||||
codeHeight.value = targetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const codeStyle = computed(() => {
|
||||
if (typeof codeHeight.value === 'number') {
|
||||
return `height: ${codeHeight.value}px;`;
|
||||
} else {
|
||||
return `height: ${codeHeight.value};`;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (copyTimeout) clearTimeout(copyTimeout);
|
||||
clear();
|
||||
});
|
||||
|
||||
console.log("shiki codeblock rendered in", Date.now() - start);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col my-2 rounded-xl overflow-hidden">
|
||||
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-highlight)] justify-between">
|
||||
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-hover)] justify-between">
|
||||
<div class="capitalize">
|
||||
{{ parsed?.displayLang }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyCode()"
|
||||
class="flex items-center px-1 gap-0.5 rounded-md hover:bg-[var(--color-highlight)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
class="flex items-center px-1 gap-0.5 rounded-md hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Copy
|
||||
<Icon v-if="!copied" name="mynaui:copy" class="text-4 text-[var(--color-text-subtle)]" />
|
||||
<Icon v-if="!copied" name="mynaui:copy" class="text-4 text-[var(--text-secondary)]" />
|
||||
<Icon v-else name="mynaui:check" class="text-4 text-emerald-500" />
|
||||
</button>
|
||||
|
||||
<button @click="collapseCode()"
|
||||
class="flex items-center justify-center h-5.5 w-5.5 rounded-md hover:bg-[var(--color-highlight)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<button @click="collapsed = !collapsed"
|
||||
class="flex items-center justify-center h-5.5 w-5.5 rounded-md hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:chevron-down"
|
||||
:class="['text-4 h-4 w-4 text-[var(--color-text-subtle)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', collapsed ? '-rotate-90' : '']" />
|
||||
:class="['text-4 h-4 w-4 text-[var(--text-secondary)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', collapsed ? '-rotate-90' : '']" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="codeBlockRef"
|
||||
class="font-mono overflow-hidden code-container transition-height duration-300 ease-in-out"
|
||||
:style="`--line-number-width: ${lineNumberWidth}ch; ${codeStyle}`" :id="`code-${renderId}`"
|
||||
v-html="parsed?.html">
|
||||
<div class="grid transition-all duration-350 ease-in-out"
|
||||
:class="collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'"
|
||||
:style="`--line-number-width: ${lineNumberWidth}ch;`" :id="`code-${renderId}`">
|
||||
<div class="overflow-hidden code-container" v-html="parsed?.html"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.code-container>pre {
|
||||
overflow-x: auto;
|
||||
overflow: auto hidden;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
padding: 1rem;
|
||||
line-height: 0;
|
||||
|
||||
@@ -19,6 +19,6 @@ if (props.error !== null && props.error !== undefined) {
|
||||
<template>
|
||||
<span class="text-sm text-[var(--color-error)]">Generation failed</span>
|
||||
<div class="text-sm">
|
||||
<ShikiHighlight :code="code ?? 'An unknown error occurred'" lang="json" />
|
||||
<ShikiHighlight :code="code ?? 'An unknown error occurred'" language="json" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,35 +51,25 @@ const toggleReasoning = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="toggleReasoning" :class="[
|
||||
'w-full hover:bg-[var(--color-highlight)] rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
part.finished ? '' : 'cursor-default'
|
||||
]">
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
|
||||
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
|
||||
<div class="text-[--text-tertiary]">
|
||||
<button @click="toggleReasoning" :class="[
|
||||
'w-full hover:bg-[var(--color-hover)] rounded-lg p-1 flex justify-between items-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
part.finished ? '' : 'cursor-default'
|
||||
]">
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
|
||||
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
|
||||
</div>
|
||||
Deep Thinking
|
||||
</div>
|
||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
|
||||
</button>
|
||||
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
|
||||
:class="['reasoning-contaizner max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]">
|
||||
<div class="p-2">
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||
</div>
|
||||
Deep Thinking
|
||||
</div>
|
||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
|
||||
</button>
|
||||
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
|
||||
:class="['reasoning-contaizner p-2 text-[--color-reasoning] max-h-[min(40vh,320px)] overflow-y-auto', scrollState]">
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.reasoning-contaizner.middle {
|
||||
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.top {
|
||||
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.bottom {
|
||||
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
||||
}
|
||||
</style>
|
||||
@@ -19,7 +19,7 @@ const indicatorStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const shiki = await getShikiHighlighter();
|
||||
const { $shiki } = useNuxtApp();
|
||||
|
||||
const html = ref('');
|
||||
const lineNumberWidth = ref(1);
|
||||
@@ -87,15 +87,15 @@ const input: ComputedRef<string> = computed(() => {
|
||||
|
||||
watch(
|
||||
input,
|
||||
(newCode) => {
|
||||
async (newCode) => {
|
||||
let lang = 'json';
|
||||
try {
|
||||
shiki.getLanguage(lang);
|
||||
$shiki.getLanguage(lang);
|
||||
} catch (e) {
|
||||
lang = 'text';
|
||||
}
|
||||
|
||||
html.value = shiki.codeToHtml(newCode, {
|
||||
html.value = await $shiki.codeToHtml(newCode, {
|
||||
lang,
|
||||
themes: {
|
||||
dark: 'vitesse-dark',
|
||||
@@ -109,8 +109,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border rounded-lg border-[var(--color-highlight)] flex flex-row h-80 overflow-hidden">
|
||||
<div class="flex items-center gap-2 flex-col border-r border-[var(--color-highlight)] p-1 relative shrink-0">
|
||||
<div class="w-full border rounded-lg border-[var(--color-border)] flex flex-row h-80 overflow-hidden">
|
||||
<div class="flex items-center gap-2 flex-col border-r border-[var(--color-border)] p-1 relative shrink-0">
|
||||
<button @click="activeTab = 'input'" :class="[
|
||||
'function-call-tab-selector',
|
||||
activeTab === 'input' ? 'text-orange-6' : ''
|
||||
@@ -207,7 +207,7 @@ watch(
|
||||
}
|
||||
|
||||
.function-call-tab-selector:hover:enabled {
|
||||
background-color: var(--color-highlight);
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.function-call-tab-selector:disabled {
|
||||
|
||||
@@ -17,17 +17,17 @@ const toggleDebugToolCall = () => {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="select-none w-full hover:bg-[var(--color-highlight)] group rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
class="select-none w-full hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
|
||||
<!-- Explicityly avoid setting the name via a reactive value, otherwise you risk corrupting the icon with that name globally -->
|
||||
<Icon v-if="toolCall.status === 'pending'" name="svg-spinners:180-ring-with-bg"
|
||||
class="w-3 h-3 text-[var(--color-subtle)]" />
|
||||
class="w-3 h-3 text-[var(--text-secondary)]" />
|
||||
<Icon v-else-if="toolCall.status === 'failed'" name="mynaui:x-solid"
|
||||
class="w-3 h-3 text-[#ff3b3b]" />
|
||||
<Icon v-else name="mynaui:tool" class="w-3 h-3 text-[var(--color-subtle)]" />
|
||||
<Icon v-else name="mynaui:tool" class="w-3 h-3 text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{{ toolCall.toolName }}
|
||||
</div>
|
||||
@@ -35,8 +35,8 @@ const toggleDebugToolCall = () => {
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<button @click="toggleDebugToolCall"
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-highlight)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:search" class="w-3 h-3 text-[var(--color-subtle)]" />
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:search" class="w-3 h-3 text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ defineProps<{
|
||||
<span class="flex items-center gap-1"
|
||||
v-if="message.generation?.status === 'pending' && (message.parts || []).length === 0">
|
||||
<Icon name="svg-spinners:pulse-2" class="text-4" />
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
Preparing generating...
|
||||
</span>
|
||||
</span>
|
||||
@@ -34,7 +34,7 @@ defineProps<{
|
||||
<Error :error="message.generation.error" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between text-zinc-400 dark:text-zinc-600 text-xs"
|
||||
<div class="flex flex-row justify-between text-[var(--text-tertiary)] text-xs"
|
||||
v-if="message.generation && message.generation.status !== 'pending'">
|
||||
<span class="flex items-center gap-1">
|
||||
<ModelIcon :size="12" :model-id="message.generation.modelId" />
|
||||
|
||||
@@ -8,6 +8,6 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--color-highlight)] py-2 px-3 rounded-xl"
|
||||
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl"
|
||||
:content="message.content!" :id="message.id" />
|
||||
</template>
|
||||
|
||||
@@ -119,7 +119,7 @@ const messageCount = computed(() => {
|
||||
<button :inert="focusedIndex === 0" @click="focusedIndex = focusedIndex! - 1">
|
||||
<Icon name="mynaui:chevron-left" :class="['w-4 h-4', focusedIndex === 0 ? 'opacity-0' : '']" />
|
||||
</button>
|
||||
<span class="text-xs text-[var(--color-muted)]">
|
||||
<span class="text-xs text-[var(--text-secondary)]">
|
||||
{{ focusedIndex + 1 }} / {{ messageCount }}
|
||||
</span>
|
||||
<button :inert="focusedIndex + 1 === messageCount" @click="focusedIndex = focusedIndex + 1">
|
||||
@@ -129,18 +129,18 @@ const messageCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="activeMessage.generation?.status === 'pending' ? 'opacity-0!' : ''"
|
||||
class="self-end mt-1 w-fit bg-[var(--color-highlight)] text-[var(--color-subtle)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<div v-show="activeMessage.generation?.status !== 'pending'"
|
||||
class="self-end mt-1 w-fit bg-[var(--bg-container)] text-[var(--text-secondary)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<button @click="regenerateMessage"
|
||||
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-highlight)]">
|
||||
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
|
||||
<Icon name="mynaui:refresh" class="text-4.5" />
|
||||
</button>
|
||||
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }"
|
||||
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-highlight)]">
|
||||
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
|
||||
<Icon :name="copied ? 'mynaui:check' : 'mynaui:copy'" class="text-4.5" />
|
||||
</button>
|
||||
<button @click="deleteMessage"
|
||||
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-highlight)]">
|
||||
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-hover)]">
|
||||
<Icon name="mynaui:trash" class="text-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,8 @@ const config = computed(() => getModelConfig(props.modelId));
|
||||
v-bind="config.props" />
|
||||
<!-- Fallback if no logo matches -->
|
||||
<div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }"
|
||||
class="bg-[var(--color-highlight)] rounded-md flex items-center justify-center">
|
||||
<Icon name="tabler:brain" class="text-5 text-[var(--color-muted)]" />
|
||||
class="bg-[var(--bg-container)] rounded-md flex items-center justify-center">
|
||||
<Icon name="tabler:brain" class="text-5 text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,8 +19,6 @@ const props = withDefaults(defineProps<{
|
||||
showEdit: false,
|
||||
});
|
||||
|
||||
const { Big } = await import('big.js');
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const formatContextWindow = (window: number | null | undefined): string => {
|
||||
@@ -38,15 +36,6 @@ const hasInputModality = (modality: string): boolean => {
|
||||
return (props.model.attributes.inputModalities as Readonly<Set<string>>).has(modality);
|
||||
};
|
||||
|
||||
const formatBig = (bigValue: Big) => {
|
||||
let str = bigValue.toString();
|
||||
|
||||
if (!str.includes('.')) return str + '.00';
|
||||
if (str.split('.')[1]!.length === 1) return str + '0';
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
const showCost = computed(() => {
|
||||
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
|
||||
});
|
||||
@@ -68,16 +57,16 @@ const deleteModel = async () => {
|
||||
<ModelIcon :class="{
|
||||
'rounded-lg overflow-hidden': size === 'large',
|
||||
'rounded-md overflow-hidden': size === 'medium' || size === 'small',
|
||||
}" :avatar="true" variant="color" :model-id="model.externalId"
|
||||
}" :avatar="true" variant="color" :key="model.externalId" :model-id="model.externalId"
|
||||
:size="size === 'small' ? '20' : size === 'medium' ? '26' : '32'" />
|
||||
|
||||
<div class="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<span class="text-[15px] font-medium text-[var(--color-text)] truncate min-w-0">
|
||||
<span class="text-[15px] font-medium text-[var(--text-primary)] truncate min-w-0">
|
||||
{{ model.name }}
|
||||
</span>
|
||||
<span v-if="showExternalId"
|
||||
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)] whitespace-nowrap truncate max-w-[240px]">
|
||||
<span v-if="showExternalId" :title="model.externalId"
|
||||
class="text-xs text-[var(--text-secondary)] px-1 py-0.5 rounded bg-[var(--bg-container)] whitespace-nowrap truncate max-w-[240px]">
|
||||
{{ model.externalId }}
|
||||
</span>
|
||||
<div v-if="showEdit" class="flex items-center gap-2">
|
||||
@@ -88,25 +77,22 @@ const deleteModel = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="details" class="flex items-center gap-1.5 flex-wrap">
|
||||
<span v-if="showReleaseDate && model.releasedAt"
|
||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap">
|
||||
<div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]">
|
||||
<span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap">
|
||||
Released on {{ model.releasedAt.toISOString().split('T')[0] }}
|
||||
</span>
|
||||
<template v-if="showCost">
|
||||
<span v-if="model.cost.prompt"
|
||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
|
||||
${{ formatBig(Big(model.cost.prompt).mul(1000000)) }}/M input
|
||||
<span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
|
||||
${{ model.cost.prompt }}/M input
|
||||
</span>
|
||||
<span v-if="model.cost.completion"
|
||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
|
||||
${{ formatBig(Big(model.cost.completion).mul(1000000)) }}/M output
|
||||
<span v-if="model.cost.completion" class="text-xs whitespace-nowrap flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
|
||||
${{ model.cost.completion }}/M output
|
||||
</span>
|
||||
<span v-if="model.cost.request && model.cost.request !== '0'"
|
||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
|
||||
class="text-xs whitespace-nowrap flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
|
||||
{{ model.cost.request }}/request
|
||||
</span>
|
||||
</template>
|
||||
@@ -131,7 +117,7 @@ const deleteModel = async () => {
|
||||
</div>
|
||||
|
||||
<span v-if="model.attributes.contextWindow"
|
||||
class="text-xs font-mono text-[var(--color-subtle)] px-1.5 py-0.5 rounded bg-[var(--color-highlight)]">
|
||||
class="text-xs font-mono text-[var(--text-secondary)] px-1.5 py-0.5 rounded bg-[var(--bg-container)]">
|
||||
{{ formatContextWindow(model.attributes.contextWindow) }}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
|
||||
const { setPage } = useSettings();
|
||||
const { allModels } = await useModels();
|
||||
@@ -20,6 +21,9 @@ const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const dropdownDirection = ref<'up' | 'down'>('up');
|
||||
const dropdownMaxHeight = ref<number | undefined>(undefined);
|
||||
|
||||
const navigatingWithKeyboard = ref(false);
|
||||
const focusedOptionId = ref<string | null>(null);
|
||||
|
||||
const findContainer = (startingElement: HTMLElement): HTMLElement | null => {
|
||||
let container: HTMLElement | null = startingElement;
|
||||
while (container) {
|
||||
@@ -58,9 +62,41 @@ const filteredProviders = computed(() => {
|
||||
return filterProvidersWithModel(props.providers, searchQuery.value).filter(p => p.enabled);
|
||||
});
|
||||
|
||||
const flatOptions = computed(() => {
|
||||
const options: { id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> }[] = [];
|
||||
for (const provider of filteredProviders.value) {
|
||||
const enabledModels = provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0);
|
||||
for (const model of enabledModels) {
|
||||
options.push({ id: model.id, model, provider });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const focusedIndex = computed(() => {
|
||||
if (!focusedOptionId.value) return -1;
|
||||
return flatOptions.value.findIndex(o => o.id === focusedOptionId.value);
|
||||
});
|
||||
|
||||
const setFocusToOption = (index: number) => {
|
||||
if (flatOptions.value.length === 0) return;
|
||||
navigatingWithKeyboard.value = true;
|
||||
|
||||
const clampedIndex = Math.max(0, Math.min(index, flatOptions.value.length - 1));
|
||||
focusedOptionId.value = flatOptions.value[clampedIndex]!.id;
|
||||
scrollFocusedIntoView();
|
||||
};
|
||||
|
||||
const scrollFocusedIntoView = () => {
|
||||
nextTick(() => {
|
||||
const focusedEl = document.getElementById(`model-option-${focusedOptionId.value}`);
|
||||
focusedEl?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
};
|
||||
|
||||
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
|
||||
selectedModel.value = { ...model, provider };
|
||||
isOpen.value = false;
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
watch(() => props.providers, () => {
|
||||
@@ -89,34 +125,69 @@ watch(isOpen, (open) => {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
});
|
||||
} else {
|
||||
focusedOptionId.value = null;
|
||||
searchQuery.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const handleInputKeypress = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
const closeDropdown = () => {
|
||||
isOpen.value = false;
|
||||
navigatingWithKeyboard.value = false;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// TODO: in the settings page, pressing escape closes BOTH
|
||||
// the settings modal and the model selector dropdown
|
||||
if (event.key === 'Escape') {
|
||||
isOpen.value = false;
|
||||
const handleSearchKeyDown = (event: KeyboardEvent) => {
|
||||
if (flatOptions.value.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
closeDropdown();
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (focusedIndex.value === -1 || focusedIndex.value === flatOptions.value.length - 1) {
|
||||
setFocusToOption(0);
|
||||
} else {
|
||||
setFocusToOption(focusedIndex.value + 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (focusedIndex.value === -1 || focusedIndex.value === 0) {
|
||||
setFocusToOption(flatOptions.value.length - 1);
|
||||
} else {
|
||||
setFocusToOption(focusedIndex.value - 1);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setFocusToOption(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setFocusToOption(flatOptions.value.length - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
console.log("enter", focusedOptionId.value, flatOptions.value[0]);
|
||||
if (focusedOptionId.value) {
|
||||
const option = flatOptions.value.find(o => o.id === focusedOptionId.value);
|
||||
if (option) {
|
||||
selectModel(option.model, option.provider);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (flatOptions.value.length > 0) {
|
||||
selectModel(flatOptions.value[0]!.model, flatOptions.value[0]!.provider);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
useClickOutside(dropdownRef, () => {
|
||||
isOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
useClickOutside(dropdownRef, closeDropdown);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -125,8 +196,8 @@ onUnmounted(() => {
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
|
||||
:class="[
|
||||
isOpen
|
||||
? 'bg-[var(--color-highlight)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)]',
|
||||
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)]',
|
||||
]">
|
||||
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
|
||||
:model-id="selectedModel.externalId" size="22" />
|
||||
@@ -142,53 +213,58 @@ onUnmounted(() => {
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
|
||||
<div v-show="isOpen" ref="dropdownContentRef" :class="[
|
||||
dropdownDirection === 'up' ? 'bottom-full mb-2 origin-bottom' : 'top-full mt-2 origin-top',
|
||||
]" :style="{ maxHeight: dropdownMaxHeight ? `${dropdownMaxHeight}px` : '460px', height: 'auto' }"
|
||||
class="absolute left-0 max-w-[420px] w-full flex flex-col rounded-xl border border-[var(--color-highlight)] bg-[var(--color-neutral)] shadow-lg overflow-hidden z-50">
|
||||
<div v-if="isOpen" ref="dropdownContentRef" role="listbox" aria-label="Select model"
|
||||
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :class="[
|
||||
dropdownDirection === 'up' ? 'bottom-full mb-2 origin-bottom' : 'top-full mt-2 origin-top',
|
||||
]" :style="{ maxHeight: dropdownMaxHeight ? `${dropdownMaxHeight}px` : '460px', height: 'auto' }"
|
||||
class="absolute left-0 max-w-[420px] w-full flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden z-50">
|
||||
<div>
|
||||
<div class="relative">
|
||||
<Icon name="mynaui:search"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--color-text-subtle)]" />
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]" />
|
||||
<input ref="searchInputRef" v-model="searchQuery" autocomplete="off" name="search"
|
||||
@keypress="handleInputKeypress" type="text" placeholder="Search models..."
|
||||
class="w-full pl-9 pr-3 py-2 text-sm text-[var(--color-text)] bg-transparent placeholder-[var(--color-text-subtle)] outline-none" />
|
||||
@keydown="handleSearchKeyDown" type="text" placeholder="Search models..."
|
||||
class="placeholder:text-[var(--text-tertiary)] w-full pl-9 pr-3 py-2 text-sm text-[var(--text-primary)] bg-transparent placeholder-[var(--text-secondary)] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none max-w-full overflow-hidden">
|
||||
<div v-if="filteredProviders.length === 0"
|
||||
class="px-4 py-8 text-center text-sm text-[var(--color-muted)]">
|
||||
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
|
||||
No models found
|
||||
</div>
|
||||
|
||||
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
|
||||
<div
|
||||
class="px-4 py-1.5 text-[13px] font-medium text-[var(--color-muted)] capitalize tracking-wider flex justify-between">
|
||||
<div v-if="provider.models.filter(m => m.enabled).length > 0"
|
||||
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] capitalize tracking-wider flex justify-between">
|
||||
{{ provider.name }}
|
||||
<button @click="isOpen = true; setPage('providers', provider.id)"
|
||||
class="flex h-4.5 w-4.5 items-center justify-center hover:bg-[var(--color-highlight)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:cog-four" class="text-3.5" />
|
||||
class="flex h-5 w-5 items-center justify-center hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:cog-four" class="text-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="model in provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)"
|
||||
:key="model.id" @click="selectModel(model, provider); isOpen = false"
|
||||
class="text-white w-full min-h-9 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-highlight-low)] transition-colors duration-150"
|
||||
:class="{ 'bg-[var(--color-highlight-low)]': selectedModel?.id === model.id }">
|
||||
<button v-for="model in provider.models.filter(m => m.enabled).sort(sortByReleaseDate)"
|
||||
:key="model.id" :id="`model-option-${model.id}`" role="option"
|
||||
:aria-selected="focusedOptionId === model.id"
|
||||
@click="selectModel(model, provider); isOpen = false"
|
||||
class="text-white w-full min-h-9 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-hover)] transition-colors duration-150"
|
||||
:class="{
|
||||
'bg-[var(--color-hover)]': selectedModel?.id === model.id,
|
||||
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === model.id && navigatingWithKeyboard
|
||||
}">
|
||||
<ModelInfo :model="model" size="medium" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-1 border-t border-[var(--color-highlight-low)]">
|
||||
<div class="p-1 border-t border-[var(--color-border)]">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)] rounded-lg transition-colors duration-150"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
|
||||
@click="isOpen = false; setPage('providers');">
|
||||
<Icon name="mynaui:cog-four" class="text-4" />
|
||||
<Icon name="mynaui:cog-four" class="text-4.5" />
|
||||
<span>Manage Providers</span>
|
||||
<Icon name="mynaui:arrow-right" class="text-3.5 ml-auto" />
|
||||
<Icon name="mynaui:arrow-right" class="text-4 ml-auto" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
||||
import { providerBaseUrls, SupportedModalities, type Model } from '~/types/model';
|
||||
import { providerBaseUrls, type Model } from '~/types/model';
|
||||
import { useSettings } from '~/composables/useSettings';
|
||||
import ModelItem from './ModelItem.vue';
|
||||
// @ts-ignore
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const { pageParams } = useSettings();
|
||||
@@ -96,29 +96,17 @@ const updateProxyUrl = async (value: string) => {
|
||||
const fetchingModels = ref(false);
|
||||
|
||||
const fetchModels = async () => {
|
||||
const { user } = useAuth();
|
||||
const { user } = useAuth()
|
||||
|
||||
fetchingModels.value = true;
|
||||
|
||||
try {
|
||||
const [providerResponse, devDataResponse] = await Promise.all([
|
||||
$fetch(`/api/provider/${provider.value!.id}/models`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
providerApiKey: apiKey.value
|
||||
})
|
||||
}) as any,
|
||||
$fetch('https://models.dev/api.json') as any
|
||||
]);
|
||||
|
||||
// TODO: get model details correctly for ollama-cloud models
|
||||
let providerType = provider.value!.type as string;
|
||||
if (providerType === 'ollama') {
|
||||
providerType = 'ollama-cloud';
|
||||
}
|
||||
|
||||
const modelDetails = devDataResponse[providerType]?.models || {};
|
||||
console.log(modelDetails);
|
||||
const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
providerApiKey: apiKey.value
|
||||
})
|
||||
}) as any;
|
||||
|
||||
const existingModelsMap = new Map(
|
||||
(provider.value?.models || []).map((m: any) => [m.externalId, m])
|
||||
@@ -127,103 +115,34 @@ const fetchModels = async () => {
|
||||
const toInsert: any[] = [];
|
||||
const toUpdate: { id: string, data: any }[] = [];
|
||||
|
||||
providerResponse.models.forEach((pModel: any) => {
|
||||
let slug: string = pModel.id.toLowerCase();
|
||||
if (providerType === 'ollama-cloud') {
|
||||
slug = slug.replace(/:cloud$/, '');
|
||||
slug = slug.replace(/-cloud$/, '');
|
||||
slug = slug.replace(/:latest$/, '');
|
||||
}
|
||||
|
||||
let info = modelDetails[slug] || {};
|
||||
|
||||
const capabilities = [];
|
||||
|
||||
if (info.reasoning) {
|
||||
capabilities.push('reasoning');
|
||||
}
|
||||
|
||||
if (info.tool_call) {
|
||||
capabilities.push('tools');
|
||||
}
|
||||
|
||||
let inputModalities = info.modalities?.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
|
||||
if (inputModalities === undefined || inputModalities.length === 0) {
|
||||
inputModalities = ['text'];
|
||||
}
|
||||
|
||||
let outputModalities = info.modalities?.output.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
|
||||
if (outputModalities === undefined || outputModalities.length === 0) {
|
||||
outputModalities = ['text'];
|
||||
}
|
||||
|
||||
// merge pModel.attributes and info.modalities, with a preference for pModel.attributes
|
||||
const attributes = {
|
||||
inputModalities: new Set(inputModalities),
|
||||
outputModalities: new Set(outputModalities),
|
||||
capabilities,
|
||||
contextWindow: pModel.context_length || info.limit?.context || null,
|
||||
supported_parameters: new Set(pModel.supported_parameters || ["temperature", "max_tokens"]),
|
||||
...(pModel.attributes || {}),
|
||||
};
|
||||
|
||||
let cost;
|
||||
if (pModel.pricing === undefined) {
|
||||
cost = {}
|
||||
} else {
|
||||
cost = {
|
||||
prompt: pModel.pricing.prompt || null,
|
||||
completion: pModel.pricing.completion || null,
|
||||
request: pModel.pricing.request || null,
|
||||
image: pModel.pricing.image || null,
|
||||
imageTokens: pModel.pricing.image_tokens || null,
|
||||
imageOutput: pModel.pricing.image_output || null,
|
||||
audio: pModel.pricing.audio || null,
|
||||
audioOutput: pModel.pricing.audio_output || null,
|
||||
inputAudioCache: pModel.pricing.input_audio_cache || null,
|
||||
webSearch: pModel.pricing.web_search || null,
|
||||
internalReasoning: pModel.pricing.internal_reasoning || null,
|
||||
inputCacheRead: pModel.pricing.input_cache_read || null,
|
||||
inputCacheWrite: pModel.pricing.input_cache_write || null,
|
||||
discount: pModel.pricing.discount || null,
|
||||
}
|
||||
}
|
||||
|
||||
const existing = existingModelsMap.get(pModel.id);
|
||||
for (const model of modelsData.models) {
|
||||
const existing = existingModelsMap.get(model.id);
|
||||
|
||||
if (existing) {
|
||||
// UPDATE logic: Remove 'id' from the payload as per Triplit requirements
|
||||
const { id, ...existingWithoutId } = existing;
|
||||
const { id, ...existingWithoutId } = model;
|
||||
console.log("existingWithoutId", existingWithoutId);
|
||||
|
||||
toUpdate.push({
|
||||
id: existing.id,
|
||||
data: {
|
||||
...existingWithoutId,
|
||||
name: existing.name || pModel.name || info.name || pModel.id,
|
||||
cost,
|
||||
attributes, // Update tech specs
|
||||
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
|
||||
}
|
||||
data: existingWithoutId,
|
||||
});
|
||||
} else {
|
||||
// INSERT logic: This is a brand new model
|
||||
toInsert.push({
|
||||
userId: user.value?.id,
|
||||
providerId: provider.value!.id,
|
||||
externalId: pModel.id,
|
||||
name: pModel.name || info.name || pModel.id,
|
||||
userId: user.value?.id!,
|
||||
externalId: model.id,
|
||||
providerId: provider.value!.id!,
|
||||
name: model.name || model.id,
|
||||
cost: model.cost || {},
|
||||
attributes: model.attributes,
|
||||
isCustom: false,
|
||||
enabled: false,
|
||||
cost,
|
||||
attributes,
|
||||
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
|
||||
createdAt: new Date(),
|
||||
releasedAt: model.releasedAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// delete models that are not in the API response and are not custom models
|
||||
const apiModelIds = new Set(providerResponse.models.map((p: any) => p.id));
|
||||
const apiModelIds = new Set(existingModelsMap.values().map((m: any) => m.externalId));
|
||||
const toDelete = (provider.value?.models || []).filter((m: any) =>
|
||||
!m.isCustom && !apiModelIds.has(m.externalId)
|
||||
);
|
||||
@@ -250,12 +169,12 @@ const deleteModels = async () => {
|
||||
|
||||
const enabledModels = computed(() =>
|
||||
filterModels(provider.value?.models.filter(m => m.enabled === true) as Model[] || [], modelSearch.value)
|
||||
.sort((a, b) => a.releasedAt && b.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)
|
||||
.sort(sortByReleaseDate)
|
||||
)
|
||||
|
||||
const disabledModels = computed(() =>
|
||||
filterModels(provider.value?.models.filter(m => m.enabled === false) as Model[] || [], modelSearch.value)
|
||||
.sort((a, b) => a.releasedAt && b.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)
|
||||
.sort(sortByReleaseDate)
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -274,13 +193,13 @@ defineEmits(['navigate']);
|
||||
|
||||
<div class="flex flex-row justify-between gap-16">
|
||||
<label class="whitespace-nowrap" for="provider-api-key">API Key</label>
|
||||
<div
|
||||
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10">
|
||||
<input class="w-full p-0 pl-2 py-1 bg-transparent" :type="apiKeyVisible ? 'text' : 'password'"
|
||||
id="provider-api-key" :value="apiKey" autocomplete="false" spellcheck="false"
|
||||
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
|
||||
<input class="placeholder:text-[var(--text-tertiary)] w-full p-0 pl-2 py-1 bg-transparent"
|
||||
:type="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :value="apiKey"
|
||||
autocomplete="false" spellcheck="false"
|
||||
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
|
||||
<button @click="apiKeyVisible = !apiKeyVisible"
|
||||
class="text-sm p-2 text-[var(--color-muted)] hover:text-[var(--color-text)]">
|
||||
class="text-sm p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
||||
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4 min-h-4 min-w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -288,16 +207,16 @@ defineEmits(['navigate']);
|
||||
|
||||
<div class="flex flex-row justify-between gap-16">
|
||||
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
|
||||
<div
|
||||
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10">
|
||||
<input :placeholder="providerBaseUrls[provider!.type] ?? ''" class="w-full px-2 py-1 bg-transparent"
|
||||
type="text" id="provider-proxy-url" :value="apiProxyUrl"
|
||||
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
|
||||
<input :placeholder="providerBaseUrls[provider!.type] ?? ''"
|
||||
class="placeholder:text-[var(--text-tertiary)] w-full px-2 py-1 bg-transparent" type="text"
|
||||
id="provider-proxy-url" :value="apiProxyUrl"
|
||||
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-center text-xs">
|
||||
<p class="text-[var(--color-muted)]">
|
||||
<p class="text-[var(--text-secondary)]">
|
||||
<Icon name="mynaui:lock" /> Your API key is encrypted using <a
|
||||
href="https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01">AES-GCM</a> encryption.
|
||||
</p>
|
||||
@@ -307,26 +226,27 @@ defineEmits(['navigate']);
|
||||
<div class="pt-5 justify-between w-full flex flex-wrap gap-y-1 items-center">
|
||||
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
|
||||
Model List
|
||||
<span class="text-sm text-[var(--color-muted)] font-normal text-xs flex items-center gap-1">
|
||||
{{ provider?.models.length }} models available <button @click="deleteModels">
|
||||
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
|
||||
{{ provider?.models.length }} models available <button
|
||||
class="p-0.5 hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
|
||||
@click="deleteModels">
|
||||
<Icon name="mynaui:x-solid" />
|
||||
</button>
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex items-center justify-center px-2 py-1 bg-[var(--color-highlight)] text-xs rounded-md">
|
||||
<input class="p-0 bg-transparent" v-model="modelSearch" type="text"
|
||||
placeholder="Search models..." />
|
||||
<div class="flex items-center justify-center px-2 py-1 bg-[var(--bg-container)] text-xs rounded-md">
|
||||
<input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch"
|
||||
type="text" placeholder="Search models..." />
|
||||
<button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''"
|
||||
class="right-1 hover:bg-[var(--color-highlight)] rounded p-0.5">
|
||||
<Icon name="mynaui:x" class="text-3.5 block text-[var(--color-subtle)]" />
|
||||
class="right-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5">
|
||||
<Icon name="mynaui:x" class="text-3.5 block text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="fetchModels"
|
||||
class="whitespace-nowrap flex bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] text-sm rounded-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] text-sm rounded-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
|
||||
fetch models
|
||||
</button>
|
||||
@@ -335,14 +255,14 @@ defineEmits(['navigate']);
|
||||
|
||||
<div v-if="provider?.models?.length === 0" class="flex flex-row items-center justify-center gap-2 mt-2">
|
||||
<Icon name="mynaui:info-circle" class="text-4" />
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
No models found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ClientOnly v-else>
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<span v-if="enabledModels.length > 0" class="text-sm text-[var(--color-muted)]">
|
||||
<span v-if="enabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
|
||||
Enabled
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -359,7 +279,7 @@ defineEmits(['navigate']);
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
|
||||
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--color-muted)]">
|
||||
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
|
||||
Disabled
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -383,19 +303,3 @@ defineEmits(['navigate']);
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.animate-rotate {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,81 @@
|
||||
<template>
|
||||
<script setup lang="ts">
|
||||
const { settings, updateSettings } = await useUserSettings();
|
||||
const { colorScheme } = useTheme();
|
||||
|
||||
</template>
|
||||
const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta'];
|
||||
const neutrals = ['zinc', 'slate', 'obsidian'];
|
||||
|
||||
const updateAccent = (accent: string) => {
|
||||
updateSettings({ appearance: { accent } });
|
||||
};
|
||||
|
||||
const updateNeutral = (neutral: string) => {
|
||||
updateSettings({ appearance: { neutral } });
|
||||
};
|
||||
|
||||
const updateHinting = (e: Event) => {
|
||||
const hinting = parseInt((e.target as HTMLInputElement).value);
|
||||
updateSettings({ appearance: { hinting } });
|
||||
};
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 mt-4">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<h4 class="font-medium">Theme</h4>
|
||||
<div class="flex gap-2">
|
||||
<button @click="updateSettings({ appearance: { colorScheme: 'system' } })"
|
||||
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
:class="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''">
|
||||
<Icon name="tabler:device-desktop" class="text-4" />
|
||||
<span>System</span>
|
||||
</button>
|
||||
<button @click="updateSettings({ appearance: { colorScheme: 'dark' } })"
|
||||
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
:class="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''">
|
||||
<Icon name="mynaui:moon" class="text-4" />
|
||||
<span>Dark</span>
|
||||
</button>
|
||||
<button @click="updateSettings({ appearance: { colorScheme: 'light' } })"
|
||||
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
:class="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''">
|
||||
<Icon name="mynaui:sun" class="text-4" />
|
||||
<span>Light</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-sm font-medium">Accent Color</h4>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<button v-for="accent in accents" :key="accent" @click="updateAccent(accent)"
|
||||
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
|
||||
:class="[
|
||||
settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent'
|
||||
]" :style="`background-color: var(--accent-${accent})`" :title="accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-sm font-medium">Neutral Color</h4>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<button v-for="neutral in neutrals" :key="neutral" @click="updateNeutral(neutral)"
|
||||
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
|
||||
:class="[
|
||||
settings.appearance.neutral === neutral ? 'border-[var(--color-accent)]' : 'border-transparent hover:border-zinc-500'
|
||||
]" :style="`background-color: var(--palette-${neutral}-200)`" :title="neutral" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-sm font-medium">Accent Hinting</h4>
|
||||
<span class="text-xs text-zinc-500">{{ settings.appearance.hinting }}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" step="1" :value="settings.appearance.hinting" @input="updateHinting"
|
||||
class="accent-[var(--color-accent)]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,14 +96,14 @@ onUnmounted(() => {
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
|
||||
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
|
||||
<div class="absolute w-[85vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
|
||||
<div class="absolute w-[85vw] max-w-6xl h-[70vh] bg-[var(--bg-base)] rounded-2xl shadow-2xl border border-[var(--color-border)]
|
||||
overflow-hidden flex max-h-[90vh] p-2">
|
||||
<nav class="w-64 flex flex-col gap-1 mr-2 overflow-y-auto">
|
||||
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
|
||||
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="setPage" />
|
||||
|
||||
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setPage(id)"
|
||||
:class="[currentPage === id ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
|
||||
:class="[currentPage === id ? 'bg-[var(--color-hover)]' : 'hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<Icon :name="config.icon" class="w-5 h-5" />
|
||||
{{ config.label }}
|
||||
@@ -114,11 +114,11 @@ onUnmounted(() => {
|
||||
<!-- DYNAMIC CONTENT -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="flex flex-col flex-1 p-3 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
|
||||
class="flex flex-col flex-1 p-3 bg-[var(--bg-surface)] overflow-y-auto border rounded-lg border-[var(--color-border)]">
|
||||
<header class="flex items-center justify-between pl-2 pb-2 ">
|
||||
<h2 class="text-lg font-semibold m-0">{{ runtimePage.label }}</h2>
|
||||
<h2 class="text-lg font-semibold m-0 capitalize">{{ runtimePage.label }}</h2>
|
||||
<button
|
||||
class="hover:bg-[var(--color-highlight)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
|
||||
class="hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
|
||||
@click="close">
|
||||
<Icon name="mynaui:x-solid" />
|
||||
</button>
|
||||
|
||||
@@ -3,4 +3,5 @@ defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
hi
|
||||
</template>
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const props = defineProps<{
|
||||
model: ModelWithProvider;
|
||||
}>();
|
||||
@@ -8,7 +6,7 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="p-3 text-white flex max-w-full items-center justify-between gap-2 group hover:bg-[var(--color-highlight-low)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
class="p-3 text-white flex max-w-full items-center justify-between gap-2 group hover:bg-[var(--color-hover)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
|
||||
:show-release-date="true" />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Providers } from '~/types/model';
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||
|
||||
if (providers.value === undefined) throw new Error('Providers not loaded');
|
||||
|
||||
// TODO: sometimes this code can create duplicate providers
|
||||
const { user } = useAuth();
|
||||
for (const provider of Providers) {
|
||||
if (!providers.value?.find(p => p.type === provider)) {
|
||||
@@ -20,6 +22,12 @@ for (const provider of Providers) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const provider of providers.value) {
|
||||
if (!Providers.includes(provider.type)) {
|
||||
await triplit.delete('providers', provider.id);
|
||||
}
|
||||
}
|
||||
|
||||
const toggleProvider = async (id: string) => {
|
||||
const provider = providers.value!.find(p => p.id === id);
|
||||
if (!provider) return;
|
||||
@@ -39,7 +47,7 @@ defineEmits(['navigate']);
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
Enabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
|
||||
Enabled <span class="text-sm bg-[var(--bg-container)] px-2 rounded-md py-0.5 text-[var(--text-secondary)]">
|
||||
{{providers?.filter(p => p.enabled).length}}
|
||||
</span>
|
||||
</h2>
|
||||
@@ -47,21 +55,25 @@ defineEmits(['navigate']);
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
|
||||
<hr class="border-t border-[var(--color-highlight)]" />
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="w-8 h-8 text-[var(--text-primary)]" />
|
||||
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
|
||||
</div>
|
||||
<hr class="border-t border-[var(--color-border)]" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- <input type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-highlight)]" /> -->
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
|
||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
Disabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
|
||||
Disabled <span class="text-sm bg-[var(--bg-container)] px-2 rounded-md py-0.5 text-[var(--text-secondary)]">
|
||||
{{providers?.filter(p => !p.enabled).length}}
|
||||
</span>
|
||||
</h2>
|
||||
@@ -69,14 +81,18 @@ defineEmits(['navigate']);
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
|
||||
<hr class="border-t border-[var(--color-highlight)]" />
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="w-8 h-8 text-[var(--text-primary)]" />
|
||||
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
|
||||
</div>
|
||||
<hr class="border-t border-[var(--color-border)]" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- <input type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-highlight)]" /> -->
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
|
||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const { pageParams } = useSettings();
|
||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||
|
||||
@@ -12,28 +14,36 @@ defineEmits(['navigate']);
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button @click="$emit('navigate', 'general')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:chevron-left" class="text-4" /> Back to General
|
||||
</button>
|
||||
|
||||
<button @click="$emit('navigate', 'providers')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:envelope-open" class="text-4" /> All
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
|
||||
<span>{{ p.name }}</span>
|
||||
:class="['capitalize flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-hover)]' : '']">
|
||||
<div class="flex items-center gap-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="w-4 h-4 text-[var(--text-primary)]" />
|
||||
<span>{{ p.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
|
||||
@click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
|
||||
<span>{{ p.name }}</span>
|
||||
:class="['capitalize flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-hover)]' : '']">
|
||||
<div class="flex items-center gap-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="w-4 h-4 text-[var(--text-primary)]" />
|
||||
<span>{{ p.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,31 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
const triplit = useTriplitClient();
|
||||
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels();
|
||||
const { settings, unsubscribe: unsubscribeSettings } = await useUserSettings();
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const { providers, allModels } = await useModels();
|
||||
const { settings, updateSettings } = await useUserSettings();
|
||||
|
||||
const toggle = async (key: string) => {
|
||||
console.log(key);
|
||||
|
||||
await triplit.update('settings', settings.value.id, {
|
||||
const current = (settings.value.systemAssistants as any)[key];
|
||||
await updateSettings({
|
||||
systemAssistants: {
|
||||
...settings.value.systemAssistants,
|
||||
[key]: {
|
||||
// @ts-ignore
|
||||
...settings.value.systemAssistants[key],
|
||||
// @ts-ignore
|
||||
enabled: !settings.value.systemAssistants[key].enabled
|
||||
...current,
|
||||
enabled: !current.enabled
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateModel = async (key: string, model: ModelWithProvider | undefined | null) => {
|
||||
await triplit.update('settings', settings.value.id, {
|
||||
const current = (settings.value.systemAssistants as any)[key];
|
||||
await updateSettings({
|
||||
systemAssistants: {
|
||||
...settings.value.systemAssistants,
|
||||
[key]: {
|
||||
// @ts-ignore
|
||||
...settings.value.systemAssistants[key],
|
||||
...current,
|
||||
modelId: model?.id ?? null
|
||||
}
|
||||
}
|
||||
@@ -40,23 +36,19 @@ const getModel = (id: string | null | undefined) => {
|
||||
|
||||
defineEmits(['navigate']);
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeModels?.();
|
||||
unsubscribeSettings?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 flex-grow">
|
||||
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants">
|
||||
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key">
|
||||
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
|
||||
<h4 class="capitalize">{{ key }}</h4>
|
||||
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(key)" />
|
||||
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(String(key))" />
|
||||
</label>
|
||||
<div class="flex-1 justify-between gap-4 items-center">
|
||||
<ModelSelector :providers="providers" :model-value="getModel(systemAssistant.modelId)"
|
||||
@update:model-value="(model) => updateModel(key, model)" />
|
||||
@update:model-value="(model) => updateModel(String(key), model)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,9 @@ const { user, signOut } = useAuth();
|
||||
// particular)
|
||||
const cachedUser = ref(user.value);
|
||||
|
||||
const dropdownRef = ref<HTMLDivElement | null>(null);
|
||||
const dropdownOpen = ref(false);
|
||||
|
||||
watch(user, () => {
|
||||
if (user.value === null) return;
|
||||
|
||||
@@ -20,8 +23,6 @@ const { toggle: toggleSettings } = useSettings();
|
||||
|
||||
const { isHovered } = useSidenavContext();
|
||||
|
||||
const profileOpen = ref(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
assert('disconnect' in triplit);
|
||||
@@ -30,41 +31,62 @@ const handleLogout = async () => {
|
||||
await navigateTo('/auth/login');
|
||||
};
|
||||
|
||||
const profileItems: DropdownItem[] = [
|
||||
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
|
||||
{
|
||||
label: 'Log out',
|
||||
icon: 'mynaui:logout',
|
||||
divider: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
useClickOutside(dropdownRef, () => {
|
||||
dropdownOpen.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center justify-between overflow-hidden">
|
||||
<Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
|
||||
<header ref="dropdownRef" class="flex items-center justify-between overflow-hidden">
|
||||
<button @click="dropdownOpen = !dropdownOpen"
|
||||
class="flex gap-1 pr-1 items-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<div
|
||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-border)]']">
|
||||
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
|
||||
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||
{{ cachedUser?.name }}
|
||||
</span>
|
||||
<div :class="['transform-origin-left-center flex-shrink-0 w-4 h-4 text-[var(--text-secondary)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
|
||||
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40'
|
||||
]">
|
||||
<Icon class="text-4 transition-transform duration-250 ease-in-out"
|
||||
:class="dropdownOpen ? 'rotate-180' : ''" name="mynaui:chevron-down" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||
<div v-if="dropdownOpen"
|
||||
class="w-full top-full text-sm mt-1 absolute z-50 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
|
||||
<button @click="dropdownOpen = false; toggleSettings()"
|
||||
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:cog-four" class="text-4.5" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<hr class="border-t border-[var(--color-border)]" />
|
||||
<button @click="handleLogout"
|
||||
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:logout" class="text-4.5" />
|
||||
<span>Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
|
||||
<!-- <Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
|
||||
verticality="descending" width="100%">
|
||||
<template #trigger="{ toggle }">
|
||||
<button aria-label="open user dropdown"
|
||||
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
|
||||
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] cursor-pointer transition-colors max-w-full"
|
||||
@click="toggle">
|
||||
<div
|
||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
|
||||
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
|
||||
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-muted)]" />
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
|
||||
cachedUser?.name
|
||||
}}</span>
|
||||
<div :class="['transform-origin-left-center flex-shrink-0 w-4 h-4 text-[var(--color-muted)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
|
||||
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40'
|
||||
]">
|
||||
<Icon class="text-4" name="mynaui:chevron-down" />
|
||||
</div>
|
||||
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</Dropdown> -->
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,20 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
const route = useRoute();
|
||||
const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
||||
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
|
||||
|
||||
const activeAgent = computed(() => getAgent(route.params.id as string));
|
||||
|
||||
const { isHovered } = useSidenavContext();
|
||||
const { isHovered, sidebarWidth } = useSidenavContext();
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeAgents?.();
|
||||
});
|
||||
|
||||
const agentDropdownOpen = ref(false);
|
||||
const toggleDropdown = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const agentItems: DropdownItem[] = [];
|
||||
if (dropdownState.open) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsFactory = () => {
|
||||
const items = [];
|
||||
for (const agent of agents.value || []) {
|
||||
items.push({
|
||||
label: agent.name,
|
||||
icon: 'mynaui:check-hexagon',
|
||||
active: activeAgent.value?.id === agent.id,
|
||||
onClick: () => navigateTo(`/agent/${agent.id}`)
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
openDropdown(e, itemsFactory, { verticality: 'descending', placement: 'center', maxWidth: '200px' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,41 +41,61 @@ const agentItems: DropdownItem[] = [];
|
||||
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'"
|
||||
:class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
|
||||
<NuxtLink to="/"
|
||||
class="flex hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-muted)] p-1.5">
|
||||
class="flex hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5">
|
||||
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<Dropdown class="overflow-hidden" v-model="agentDropdownOpen" :items="agentItems" placement="center"
|
||||
width="calc(80% - 1rem)">
|
||||
<button
|
||||
class="max-w-full transition duration-200 pr-2 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"
|
||||
@click="toggleDropdown">
|
||||
<div class="pointer-events-none flex items-center gap-1.5 max-w-full">
|
||||
<div
|
||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-border)]']">
|
||||
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl" class="w-full h-full object-cover" />
|
||||
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||
{{ activeAgent?.name }}
|
||||
</span>
|
||||
<div class="w-4 h-4 text-[var(--text-secondary)]">
|
||||
<Icon
|
||||
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
||||
name="mynaui:chevron-up-down" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- <Dropdown class="overflow-hidden" v-model="agentDropdownOpen" placement="center" width="calc(80% - 1rem)">
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
class="flex max-w-full gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg"
|
||||
class="flex max-w-full transition duration-200 gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"
|
||||
@click="toggle">
|
||||
<div
|
||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-highlight-high)]']">
|
||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-border)]']">
|
||||
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
|
||||
class="w-full h-full object-cover" />
|
||||
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||
{{ activeAgent?.name }}
|
||||
</span>
|
||||
<div class="w-4 h-4 text-[var(--color-muted)]">
|
||||
<div class="w-4 h-4 text-[var(--text-secondary)]">
|
||||
<Icon
|
||||
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
||||
name="mynaui:chevron-up-down" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
|
||||
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
|
||||
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
|
||||
:active="activeAgent?.id === agent.id" />
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</Dropdown> -->
|
||||
</header>
|
||||
</template>
|
||||
@@ -9,11 +9,11 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[
|
||||
'decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||
'decoration-none text-[var(--text-secondary)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||
props.icon ? 'px-1' : 'px-2',
|
||||
props.active
|
||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
||||
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
|
||||
]">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||
@@ -30,8 +30,8 @@ const props = defineProps<{
|
||||
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
|
||||
props.icon ? 'px-1' : 'px-2',
|
||||
props.active
|
||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
||||
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
|
||||
]">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||
|
||||
+176
-137
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { assert } from '~~/utils/assert';
|
||||
|
||||
const route = useRoute();
|
||||
const topicsListRef = ref<HTMLElement | null>(null);
|
||||
const topicsListHeight = ref('auto');
|
||||
const topicsListOpacity = ref(1);
|
||||
const topicsListScale = ref(1);
|
||||
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
|
||||
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
@@ -12,97 +11,88 @@ const activeAgent = computed(() => getAgent(route.params.id as string));
|
||||
|
||||
const topics = computed(() => {
|
||||
if (activeAgent.value === undefined) return [];
|
||||
// return the todos but sorted and in a new array do not add messages or anything to the object, JUST SORT IT
|
||||
return activeAgent.value.topics
|
||||
.map((topic) => topic)
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.reverse();
|
||||
});
|
||||
|
||||
const routeParts = computed(() => {
|
||||
return route.path.replace('/agent/', '').split('/');
|
||||
});
|
||||
|
||||
if (routeParts.value.length < 1) navigateTo('/');
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
// `/agent/agent_[uuid]` or `/agent/agent_[uuid]/topic/...`
|
||||
if (route.path.startsWith('/agent/') && !route.path.includes('/profile')) {
|
||||
return 'conversation';
|
||||
}
|
||||
|
||||
// `/agent/agent_[uuid]/profile`
|
||||
if (route.path.endsWith('/profile')) {
|
||||
return 'agent-profile';
|
||||
}
|
||||
return activeAgent.value.topics;
|
||||
});
|
||||
|
||||
const topicsOpen = ref(true);
|
||||
|
||||
function easeInOutQuad(x: number): number {
|
||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
const toggleAgentsList = () => {
|
||||
if (!topicsListRef.value) return;
|
||||
const animationLength = 200;
|
||||
let animationStart: number | null = null;
|
||||
|
||||
let startHeight: number;
|
||||
const startOpacity = topicsListOpacity.value;
|
||||
const startScale = topicsListScale.value;
|
||||
if (topicsListHeight.value === 'auto') {
|
||||
startHeight = topicsListRef.value.clientHeight;
|
||||
} else {
|
||||
startHeight = Number(topicsListHeight.value.replace('px', ''));
|
||||
}
|
||||
|
||||
const targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
|
||||
const targetOpacity = topicsOpen.value ? 0 : 1;
|
||||
const targetScale = topicsOpen.value ? 0.95 : 1;
|
||||
topicsOpen.value = !topicsOpen.value;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!animationStart) animationStart = timestamp;
|
||||
|
||||
const elapsed = timestamp - animationStart;
|
||||
const progress = Math.min(elapsed / animationLength, 1);
|
||||
|
||||
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
|
||||
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
|
||||
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
|
||||
|
||||
topicsListOpacity.value = currentOpacity;
|
||||
topicsListScale.value = currentScale;
|
||||
topicsListHeight.value = `${currentHeight}px`;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (topicsOpen.value) {
|
||||
topicsListHeight.value = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
let activeAutoRenames = reactive(new Map<string, string>());
|
||||
|
||||
const autoRenameTopic = async (topicId: string) => {
|
||||
const { setPage } = useSettings();
|
||||
const { autoRename } = useChat(route.params.id as string);
|
||||
const { autoRename, AutoRenameError } = useChat(route.params.id as string);
|
||||
|
||||
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
|
||||
if (!firstMessage) return;
|
||||
|
||||
const success = await autoRename(topicId, firstMessage.content);
|
||||
if (!success) {
|
||||
setPage('systemAssistants');
|
||||
const res = await autoRename(topicId, firstMessage.content);
|
||||
if (res.ok) {
|
||||
activeAutoRenames.set(topicId, res.data);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (res.error) {
|
||||
case AutoRenameError.NoModelSelected:
|
||||
case AutoRenameError.ModelDisabled:
|
||||
case AutoRenameError.AutoRenameDisabled: {
|
||||
setPage('systemAssistants');
|
||||
} break;
|
||||
case AutoRenameError.NoModelFound:
|
||||
case AutoRenameError.DatabaseOperationFailed:
|
||||
case AutoRenameError.FailedToGenerate:
|
||||
case AutoRenameError.FailedToDecryptProviderApiKey: {
|
||||
console.error('Failed to auto-rename:', res.error);
|
||||
|
||||
await triplit.update('topics', topicId, {
|
||||
renaming: false,
|
||||
});
|
||||
|
||||
assert('flush' in triplit);
|
||||
await triplit.flush();
|
||||
} break;
|
||||
}
|
||||
}
|
||||
|
||||
const renameTopic = (topicId: string) => {
|
||||
console.log('renameTopic', topicId);
|
||||
const cancelAutoRename = async (topicId: string) => {
|
||||
await triplit.update('topics', topicId, {
|
||||
renaming: false,
|
||||
});
|
||||
|
||||
const renameId = activeAutoRenames.get(topicId);
|
||||
if (!renameId) return;
|
||||
|
||||
await $fetch(`/api/topic/auto-rename/cancel/${renameId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
activeAutoRenames.delete(topicId);
|
||||
}
|
||||
|
||||
const renameTopicId = ref<string | null>(null);
|
||||
const newTopicName = ref('');
|
||||
|
||||
const startRename = (topicId: string, currentName: string) => {
|
||||
renameTopicId.value = topicId;
|
||||
newTopicName.value = currentName;
|
||||
nextTick(() => {
|
||||
const input = document.getElementById('topic-rename-input') as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const saveRename = async () => {
|
||||
if (renameTopicId.value && newTopicName.value.trim()) {
|
||||
await triplit.update('topics', renameTopicId.value, {
|
||||
name: newTopicName.value.trim()
|
||||
});
|
||||
}
|
||||
cancelRename();
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
renameTopicId.value = null;
|
||||
newTopicName.value = '';
|
||||
};
|
||||
|
||||
const deleteTopic = async (topicId: string) => {
|
||||
@@ -118,6 +108,70 @@ const deleteTopic = async (topicId: string) => {
|
||||
// TODO: deeply delete all messages, generations, and message_parts in the topic
|
||||
};
|
||||
|
||||
const handleNavClick = (e: MouseEvent) => {
|
||||
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||
if (!trigger) return;
|
||||
|
||||
const topicId = trigger.dataset.topicId || (trigger.closest('[data-topic-id]') as HTMLElement | null)?.dataset.topicId;
|
||||
if (!topicId) return;
|
||||
|
||||
const action = trigger.dataset.action;
|
||||
if (action === 'navigate') {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
return navigateTo(`/agent/${route.params.id}/topic/${topicId}`);
|
||||
}
|
||||
|
||||
if (action === 'toggle-dropdown') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (dropdownState.open) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsFactory = () => {
|
||||
const items = [];
|
||||
const isRenaming = activeAutoRenames.has(topicId) && topics.value?.find(t => t.id === topicId)?.renaming;
|
||||
|
||||
if (isRenaming) {
|
||||
items.push({
|
||||
label: 'Cancel Auto Rename',
|
||||
onClick: () => cancelAutoRename(topicId)
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: 'Auto Rename',
|
||||
onClick: () => autoRenameTopic(topicId)
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: 'Rename',
|
||||
disabled: isRenaming ?? false,
|
||||
onClick: () => startRename(topicId, topics.value?.find(t => t.id === topicId)?.name || '')
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
onClick: () => deleteTopic(topicId)
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
openDropdown(e, itemsFactory, { minWidth: '120px', placement: 'right' });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// simply preload the topic page
|
||||
preloadRouteComponents(`/agent/${route.params.id}/topic/42`);
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeAgents?.();
|
||||
});
|
||||
@@ -128,69 +182,54 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Agent Info Link -->
|
||||
<div class="mt-2 flex flex-col">
|
||||
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square"
|
||||
:active="pageInfo === 'agent-profile'" />
|
||||
<SidenavItem :to="`/agent/${route.params.id}/profile`" name="Agent Info" icon="mynaui:info-square"
|
||||
:active="route.path.endsWith('/profile')" />
|
||||
|
||||
<!-- Topics Section -->
|
||||
<button @click="toggleAgentsList"
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left">
|
||||
<button @click="topicsOpen = !topicsOpen"
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
|
||||
<span class="text-sm font-medium">Topics</span>
|
||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
|
||||
</button>
|
||||
|
||||
<div ref="topicsListRef" :inert="!topicsOpen" :class="{ 'overflow-y-hidden': !topicsOpen }"
|
||||
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top">
|
||||
<NuxtLink v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
|
||||
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
|
||||
'group relative decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||
'px-2',
|
||||
topic.id === route.params.topicId
|
||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
||||
]">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<div v-if="!topic.renaming" class="flex justify-between items-center w-full">
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ topic.name }}
|
||||
</span>
|
||||
<Collapsible :is-open="topicsOpen">
|
||||
<div @click="handleNavClick"
|
||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top [content-visibility:auto] [contain-intrinsic-size:0_36px]">
|
||||
<a v-for="topic in topics" :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
|
||||
:href="`/agent/${route.params.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
|
||||
'group relative decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||
'px-2',
|
||||
topic.id === route.params.topicId
|
||||
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
|
||||
]">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
|
||||
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
|
||||
@blur="saveRename"
|
||||
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
|
||||
<div v-else-if="topic.renaming" class="flex w-full">
|
||||
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
|
||||
</div>
|
||||
<span v-else
|
||||
class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ topic.name }}
|
||||
</span>
|
||||
|
||||
<Dropdown class="shrink-0 text-[var(--color-text)]" verticality="descending"
|
||||
placement="right">
|
||||
<template #trigger="{ toggle }">
|
||||
<div dots @click.prevent="toggle"
|
||||
class="opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
|
||||
viewBox="0 0 24 24"><!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ -->
|
||||
<path fill="currentColor"
|
||||
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<template #content="{ toggle }">
|
||||
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
|
||||
<button @click.prevent="autoRenameTopic(topic.id); toggle()"
|
||||
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Auto Rename
|
||||
</button>
|
||||
<button @click.prevent="renameTopic(topic.id); toggle()"
|
||||
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Rename
|
||||
</button>
|
||||
<button @click.prevent="deleteTopic(topic.id); toggle()"
|
||||
class="text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-600/20 rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<div data-action="toggle-dropdown"
|
||||
class="shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<svg class="pointer-events-none" xmlns="http://www.w3.org/2000/svg" width="18"
|
||||
height="18" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex w-full">
|
||||
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,107 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
|
||||
const agentsListRef = ref<HTMLElement | null>(null);
|
||||
const { agents, unsubscribe, createAgent } = await useAgents();
|
||||
const agentsOpen = ref(true);
|
||||
const agentsListHeight = ref('auto');
|
||||
const agentsListOpacity = ref(1);
|
||||
const agentsListScale = ref(1);
|
||||
const creatingAgent = ref(false);
|
||||
|
||||
function easeInOutQuad(x: number): number {
|
||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
const toggleAgentsList = () => {
|
||||
if (!agentsListRef.value) return;
|
||||
const animationLength = 200;
|
||||
let animationStart: number | null = null;
|
||||
|
||||
let startHeight: number;
|
||||
const startOpacity = agentsListOpacity.value;
|
||||
const startScale = agentsListScale.value;
|
||||
if (agentsListHeight.value === 'auto') {
|
||||
startHeight = agentsListRef.value.clientHeight;
|
||||
} else {
|
||||
startHeight = Number(agentsListHeight.value.replace('px', ''));
|
||||
}
|
||||
|
||||
const targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
|
||||
const targetOpacity = agentsOpen.value ? 0 : 1;
|
||||
const targetScale = agentsOpen.value ? 0.95 : 1;
|
||||
agentsOpen.value = !agentsOpen.value;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!animationStart) animationStart = timestamp;
|
||||
|
||||
const elapsed = timestamp - animationStart;
|
||||
const progress = Math.min(elapsed / animationLength, 1);
|
||||
|
||||
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
|
||||
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
|
||||
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
|
||||
|
||||
agentsListOpacity.value = currentOpacity;
|
||||
agentsListScale.value = currentScale;
|
||||
agentsListHeight.value = `${currentHeight}px`;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (agentsOpen.value) {
|
||||
agentsListHeight.value = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const newAgent = async () => {
|
||||
const triplit = useTriplitClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user.value) throw new Error('User not logged in');
|
||||
|
||||
const agent = await createAgent();
|
||||
if (!agent) throw new Error('Failed to create agent');
|
||||
|
||||
return navigateTo(`/agent/${agent.id}`);
|
||||
creatingAgent.value = true;
|
||||
try {
|
||||
const agent = await createAgent();
|
||||
if (agent) return navigateTo(`/agent/${agent.id}`);
|
||||
} finally {
|
||||
creatingAgent.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeAgents?.();
|
||||
});
|
||||
onUnmounted(() => unsubscribe?.());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-1">
|
||||
<SidenavItem name="Search" icon="mynaui:search" />
|
||||
<SidenavItem to="/" :active="true" name="Home" icon="mynaui:home" />
|
||||
<SidenavItem to="/" name="Home" icon="mynaui:home" />
|
||||
|
||||
<!-- Agents Section -->
|
||||
<!-- Header Toggle -->
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left"
|
||||
@click="toggleAgentsList()">
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg hover:bg-[var(--color-hover)] transition-colors w-full"
|
||||
@click="toggleAgentsList">
|
||||
<span class="text-sm">Agents</span>
|
||||
<Icon name="mynaui:chevron-down"
|
||||
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" />
|
||||
<Icon name="mynaui:chevron-down" class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ '-rotate-90': !agentsOpen }" />
|
||||
</button>
|
||||
|
||||
<div ref="agentsListRef" :inert="!agentsOpen"
|
||||
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
|
||||
<button @click="newAgent" :disabled="creatingAgent"
|
||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-muted)] bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
||||
<div class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||
</div>
|
||||
<span>New Agent</span>
|
||||
</button>
|
||||
<!-- Animated Section -->
|
||||
<Collapsible :is-open="agentsOpen">
|
||||
<div class="mt-1 gap-1 flex flex-col transform-origin-top pt-1 pb-1 px-1">
|
||||
<button @click="newAgent" :disabled="creatingAgent"
|
||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--text-secondary)] hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 w-full">
|
||||
<div class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||
</div>
|
||||
<span>New Agent</span>
|
||||
</button>
|
||||
|
||||
<SidenavItem
|
||||
v-for="agent in agents?.map((agent) => agent)?.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())"
|
||||
:to="`/agent/${agent.id}`" :key="agent.id" :name="agent.name" icon="mynaui:check-hexagon" />
|
||||
</div>
|
||||
<SidenavItem v-for="agent in agents" :key="agent.id" :to="`/agent/${agent.id}`" :name="agent.name"
|
||||
icon="mynaui:check-hexagon" />
|
||||
</div>
|
||||
</Collapsible>
|
||||
</nav>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -68,15 +68,18 @@ const onResizeEnd = () => {
|
||||
|
||||
let resizeAnimationFrame: number | null = null;
|
||||
|
||||
const trackInteractionMouse = () => trackInteraction('mouse');
|
||||
const trackInteractionKeyboard = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') trackInteraction('keyboard');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('mouseup', onResizeEnd);
|
||||
|
||||
// NEW: Track global interactions
|
||||
document.addEventListener('mousedown', () => trackInteraction('mouse'));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') trackInteraction('keyboard');
|
||||
});
|
||||
document.addEventListener('mousedown', trackInteractionMouse);
|
||||
document.addEventListener('keydown', trackInteractionKeyboard);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -84,10 +87,8 @@ onUnmounted(() => {
|
||||
document.removeEventListener('mouseup', onResizeEnd);
|
||||
|
||||
// NEW: Cleanup listeners
|
||||
document.removeEventListener('mousedown', () => trackInteraction('mouse'));
|
||||
document.removeEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') trackInteraction('keyboard');
|
||||
});
|
||||
document.removeEventListener('mousedown', trackInteractionMouse);
|
||||
document.removeEventListener('keydown', trackInteractionKeyboard);
|
||||
});
|
||||
|
||||
const navKind = computed(() => {
|
||||
@@ -100,9 +101,9 @@ const navKind = computed(() => {
|
||||
<template>
|
||||
<div class="relative">
|
||||
<aside ref="sidenavRef" :class="[
|
||||
'sidenav',
|
||||
open ? 'sidenav--open' : 'sidenav--closed',
|
||||
isResizing ? 'sidenav--resizing' : ''
|
||||
'h-full max-w-fit background-[var(--bg-base)] overflow-hidden will-change-width text-[var(--text-secondary)] select-none transition-[width,margin] duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
open ? 'w-full mr-2' : 'w-0 mr-0',
|
||||
isResizing ? 'transition-none' : ''
|
||||
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
|
||||
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
|
||||
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
|
||||
@@ -113,11 +114,11 @@ const navKind = computed(() => {
|
||||
<SidenavHeader v-if="navKind === 'home'" />
|
||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
||||
|
||||
<div class="flex items-center justify-end text-[var(--color-muted)] gap-0.5">
|
||||
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
||||
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'"
|
||||
:class="['flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right']">
|
||||
<button aria-label="close sidebar" @click="closeSidebar" :class="[
|
||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent transition-inherit',
|
||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit',
|
||||
]">
|
||||
<Icon name="mynaui:panel-left-close"
|
||||
:class="['transition-inherit transform-origin-right-center', isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
|
||||
@@ -125,7 +126,7 @@ const navKind = computed(() => {
|
||||
</div>
|
||||
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
|
||||
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
|
||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent text-inherit',
|
||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit',
|
||||
]">
|
||||
<Icon name="mynaui:book-plus" />
|
||||
</NuxtLink>
|
||||
@@ -134,7 +135,8 @@ const navKind = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Main Menu -->
|
||||
<div class="max-h-full h-full overflow-auto" style="scrollbar-width: thin;">
|
||||
<div
|
||||
class="max-h-full h-full overflow-auto [scrollbar-color:#888_transparent] [scrollbar-width:thin] [scrollbar-gutter:stable]">
|
||||
<SidenavNavHome v-if="navKind === 'home'" />
|
||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||
</div>
|
||||
@@ -143,12 +145,11 @@ const navKind = computed(() => {
|
||||
<div class="flex justify-between pt-2">
|
||||
<div class="flex">
|
||||
<button @click="toggleSettings()"
|
||||
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg transition-colors text-[var(--color-muted)] active:text-[var(--color-text)]">
|
||||
<Icon name="mynaui:cog-four" class="text-4" />
|
||||
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]">
|
||||
<Icon name="mynaui:cog-four" class="text-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher -->
|
||||
<div class="flex justify-end gap-1">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
@@ -161,31 +162,3 @@ const navKind = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidenav {
|
||||
height: 100%;
|
||||
max-width: fit-content;
|
||||
background: var(--color-base);
|
||||
overflow: hidden;
|
||||
will-change: width;
|
||||
color: var(--color-muted);
|
||||
user-select: none;
|
||||
transition: width 250ms cubic-bezier(0, 0.55, 0.45, 1),
|
||||
margin 250ms cubic-bezier(0, 0.55, 0.45, 1);
|
||||
}
|
||||
|
||||
.sidenav--open {
|
||||
width: 100%;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.sidenav--closed {
|
||||
width: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.sidenav--resizing {
|
||||
transition: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,62 +25,13 @@ watch(() => props.checked, (newValue) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :id="id" role="switch" class="vl-toggle-switch"
|
||||
<button :id="id" role="switch"
|
||||
class="vl-toggle-switch flex items-center w-[2.5em] h-[1.4em] rounded-[100px] bg-[var(--bg-container)] py-0.5 px-1 transition-[background-color] duration-300 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
:aria-disabled="(props.disabled === true) ? 'true' : 'false'" :aria-label="label" :aria-labelledby="id"
|
||||
:tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active"
|
||||
:data-state="(active) ? 'checked' : 'unchecked'">
|
||||
<div></div>
|
||||
<div
|
||||
class="transform-origin-center-left [will-change:tranform] relative left-0 w-[1em] h-[1em] bg-gray-100 rounded-full pointer-events-none transition-all duration-300">
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vl-toggle-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: inherit;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
width: 2.5em;
|
||||
height: 1.4em;
|
||||
background: var(--color-highlight);
|
||||
border-radius: 100px;
|
||||
padding: 0.125rem 0.25rem;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[aria-disabled="true"] div {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vl-toggle-switch div {
|
||||
transform-origin: center left;
|
||||
will-change: transform;
|
||||
position: relative;
|
||||
left: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: #f7f7f7;
|
||||
border-radius: 9999px;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] div {
|
||||
transform-origin: center right;
|
||||
transform: translateX(calc(2.5em - 1em - 0.5rem));
|
||||
}
|
||||
|
||||
.vl-toggle-switch:active div {
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"]:active div {
|
||||
transform: translateX(calc(2.5em - 1.3em - 0.5rem));
|
||||
}
|
||||
</style>
|
||||
@@ -3,34 +3,38 @@ import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
const colorMode = useColorMode();
|
||||
const { updateSettings, settings } = await useUserSettings();
|
||||
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
|
||||
|
||||
const selectTheme = (colorScheme: Theme) => {
|
||||
updateSettings({ appearance: { colorScheme } });
|
||||
};
|
||||
|
||||
const themeOptions: DropdownItem[] = [
|
||||
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
||||
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' },
|
||||
{ id: 'light', label: 'Light', icon: 'mynaui:sun', onClick: () => selectTheme('light') },
|
||||
{ id: 'dark', label: 'Dark', icon: 'mynaui:moon', onClick: () => selectTheme('dark') },
|
||||
{ id: 'system', label: 'System', icon: 'mynaui:desktop', onClick: () => selectTheme('system') },
|
||||
];
|
||||
|
||||
const currentOption = computed(
|
||||
() => themeOptions.find((option) => option.value === colorMode.preference) || themeOptions[2],
|
||||
() => themeOptions.find((option) => option.id === settings.value.appearance.colorScheme) || themeOptions[2],
|
||||
);
|
||||
|
||||
const isOpen = ref(false);
|
||||
const toggleDropdown = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const selectTheme = (item: DropdownItem) => {
|
||||
colorMode.preference = item.value as Theme;
|
||||
};
|
||||
if (dropdownState.open) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
openDropdown(e, () => themeOptions, { verticality: 'ascending', placement: 'right' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
|
||||
placement="right" width="140px">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button aria-label="Open theme switcher" @click="toggle" :class="[isOpen ? 'bg-[var(--color-highlight)] hover:text-[var(--color-subtle)] focus-visible:text-[var(--color-subtle)]' : 'bg-transparent text-[var(--color-muted)]',
|
||||
'h-7 w-7 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors active:text-[var(--color-text)]'
|
||||
]">
|
||||
<Icon :name="currentOption!.icon!" class="text-4" />
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<button aria-label="Open theme switcher" @click="toggleDropdown"
|
||||
class="h-7 w-7 flex items-center justify-center rounded-lg hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors active:text-[var(--text-primary)]">
|
||||
<Icon :name="currentOption!.icon!" class="text-5 pointer-events-none" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -4,7 +4,14 @@ import type { Entity } from "@triplit/client";
|
||||
export const useAgents = async () => {
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const { results: agents, unsubscribe } = await useQuery('agents', triplit, triplit.query('agents').Include('topics'));
|
||||
const { results: agents, unsubscribe } = await useQuery(
|
||||
'agents',
|
||||
triplit,
|
||||
triplit
|
||||
.query('agents')
|
||||
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
|
||||
.Order('createdAt', 'ASC')
|
||||
);
|
||||
|
||||
const createAgent = async (): Promise<Readonly<Entity<typeof schema, 'agents'>> | null> => {
|
||||
const { user } = useAuth();
|
||||
|
||||
+88
-41
@@ -1,8 +1,9 @@
|
||||
import type schema from "#triplit/schema";
|
||||
import type { Entity } from "@triplit/client";
|
||||
import type { ModelMessage } from "ai";
|
||||
import { nanoid } from "nanoid";
|
||||
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
|
||||
import { type Result, Ok, Err } from "~~/types/result";
|
||||
import { type Result, Ok, Err, attempt } from "~~/types/result";
|
||||
import { assert } from "~~/utils/assert";
|
||||
|
||||
export type MessageEntity = Entity<typeof schema, 'messages'> & {
|
||||
@@ -22,6 +23,7 @@ export enum ChatErrorType {
|
||||
NoAgent,
|
||||
NoUser,
|
||||
DatabaseOperationFailed,
|
||||
FailedToDecryptProviderApiKey,
|
||||
GenerationFailed,
|
||||
MarshallFailed,
|
||||
NoProviderApiKey,
|
||||
@@ -195,22 +197,27 @@ export const useChat = (agentId: string) => {
|
||||
): Promise<Result<void, ChatErrorType>> => {
|
||||
let providerApiKey: string | undefined = undefined;
|
||||
if (provider.config.apiKey !== undefined) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
providerApiKey = await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(provider.config.apiKey)
|
||||
);
|
||||
providerApiKey = await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(provider.config.apiKey)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt provider API key:', error);
|
||||
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$fetch('/api/chat/generate', {
|
||||
await $fetch('/api/chat/generate', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
messages,
|
||||
@@ -249,14 +256,17 @@ export const useChat = (agentId: string) => {
|
||||
return Err(ChatErrorType.NoUser);
|
||||
}
|
||||
|
||||
const messageId = nanoid();
|
||||
const newMessage = await triplit.insert('messages', {
|
||||
id: messageId,
|
||||
userId: user.value.id,
|
||||
topicId: topic.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
}).catch(error => {
|
||||
}).catch(async error => {
|
||||
console.error('Failed to insert message:', error);
|
||||
await triplit.delete('messages', messageId);
|
||||
return Err(ChatErrorType.DatabaseOperationFailed);
|
||||
}) as Message;
|
||||
|
||||
@@ -278,7 +288,14 @@ export const useChat = (agentId: string) => {
|
||||
presence_penalty: 0,
|
||||
};
|
||||
|
||||
return startGeneration(messages.data, args, topic, provider, model)
|
||||
return startGeneration(messages.data, args, topic, provider, model).then(async res => {
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to start generation:', res.error);
|
||||
await triplit.delete('messages', messageId);
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -373,60 +390,90 @@ export const useChat = (agentId: string) => {
|
||||
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
|
||||
}
|
||||
|
||||
const autoRename = async (topicId: string, prompt: string) => {
|
||||
enum AutoRenameError {
|
||||
AutoRenameDisabled = 0,
|
||||
NoModelSelected,
|
||||
NoModelFound,
|
||||
ModelDisabled,
|
||||
DatabaseOperationFailed,
|
||||
FailedToDecryptProviderApiKey,
|
||||
FailedToGenerate,
|
||||
}
|
||||
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
|
||||
const { settings } = await useUserSettings();
|
||||
|
||||
console.log(settings.value);
|
||||
|
||||
if (!settings.value.systemAssistants.rename.enabled) {
|
||||
return false;
|
||||
return Err(AutoRenameError.AutoRenameDisabled);
|
||||
}
|
||||
|
||||
if (!settings.value.systemAssistants.rename.modelId) {
|
||||
return false;
|
||||
return Err(AutoRenameError.NoModelSelected);
|
||||
}
|
||||
|
||||
await triplit.update('topics', topicId, {
|
||||
renaming: true
|
||||
});
|
||||
|
||||
const model = await triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider'));
|
||||
const modelResult = await attempt(triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider')));
|
||||
|
||||
if (modelResult.ok === false) {
|
||||
return Err(AutoRenameError.DatabaseOperationFailed);
|
||||
}
|
||||
|
||||
const model = modelResult.data;
|
||||
|
||||
if (!model) {
|
||||
return false;
|
||||
return Err(AutoRenameError.NoModelFound);
|
||||
}
|
||||
|
||||
if (model.enabled === false || model.provider?.enabled === false) {
|
||||
return Err(AutoRenameError.ModelDisabled)
|
||||
}
|
||||
|
||||
let providerApiKey: string | undefined = undefined;
|
||||
if (model.provider!.config.apiKey !== undefined) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
providerApiKey = await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(model.provider!.config.apiKey)
|
||||
);
|
||||
providerApiKey = await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(model.provider!.config.apiKey)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt provider API key:', error);
|
||||
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
await $fetch(`/api/topic/auto-rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
modelId: model.id,
|
||||
topicId,
|
||||
prompt,
|
||||
providerApiKey,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const res = await $fetch(`/api/topic/auto-rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
modelId: model.id,
|
||||
topicId,
|
||||
prompt,
|
||||
providerApiKey,
|
||||
}),
|
||||
});
|
||||
|
||||
return true;
|
||||
return Ok(res.renameId);
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-rename:', error);
|
||||
return Err(AutoRenameError.FailedToGenerate);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
AutoRenameError,
|
||||
autoRename,
|
||||
regenerateMessage,
|
||||
createTopic,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,10 @@ export const useModels = async () => {
|
||||
.query('providers')
|
||||
.Include('models');
|
||||
|
||||
const start = Date.now();
|
||||
nuxtApp._modelsPromise = useQuery('providers', triplit, providersQuery).then((sub) => {
|
||||
nuxtApp._modelsSubscription = sub;
|
||||
console.log("fetching providers took", Date.now() - start);
|
||||
return sub;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const useSidebar = () => {
|
||||
const sidebarWidth = useState<number>('sidebar:width', () => {
|
||||
return Number(
|
||||
useCookie('sidebar:width', {
|
||||
default: () => '226',
|
||||
default: () => '250',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
}).value,
|
||||
);
|
||||
|
||||
@@ -13,9 +13,64 @@ export const useTheme = () => {
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
|
||||
const colorScheme: { preference: Ref<'light' | 'dark' | 'system'>; value: Ref<'light' | 'dark'>; class: Ref<'light' | 'dark' | undefined> } = {
|
||||
preference: useCookie('colorScheme', {
|
||||
default: () => 'system',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
}),
|
||||
value: computed(() => {
|
||||
if (colorScheme.preference.value === 'system') {
|
||||
if (import.meta.client) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
return colorScheme.preference.value as 'light' | 'dark';
|
||||
}),
|
||||
class: ref<undefined | 'light' | 'dark'>(undefined)
|
||||
}
|
||||
|
||||
let listeningToColorScheme = false;
|
||||
const changeSystemColorScheme = (e: MediaQueryListEvent) => {
|
||||
if (colorScheme.preference.value !== 'system') return;
|
||||
|
||||
colorScheme.class.value = e.matches ? 'dark' : 'light';
|
||||
}
|
||||
watch(colorScheme.preference, () => {
|
||||
if (colorScheme.preference.value === 'system') {
|
||||
if (import.meta.client) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (!listeningToColorScheme) {
|
||||
listeningToColorScheme = true;
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', changeSystemColorScheme);
|
||||
}
|
||||
|
||||
colorScheme.class.value = prefersDark ? 'dark' : 'light';
|
||||
return;
|
||||
}
|
||||
|
||||
if (listeningToColorScheme) {
|
||||
listeningToColorScheme = false;
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', changeSystemColorScheme);
|
||||
}
|
||||
|
||||
// fallback to dark on the server
|
||||
colorScheme.class.value = 'dark';
|
||||
return;
|
||||
}
|
||||
|
||||
colorScheme.class.value = colorScheme.preference.value as 'light' | 'dark';
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
accent,
|
||||
neutral,
|
||||
hinting,
|
||||
colorScheme
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,142 @@
|
||||
import { schema } from '#triplit/schema';
|
||||
import { type Entity } from '@triplit/client';
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
// TODO: most of this code is generated by gemini 3 flash with fixups,
|
||||
// but its still bad code, so I'm going to clean it up later
|
||||
export const useUserSettings = async () => {
|
||||
const user = useAuth().user;
|
||||
const { user, loggedIn } = useAuth();
|
||||
const triplit = useTriplitClient();
|
||||
const { accent, neutral, hinting, colorScheme } = useTheme();
|
||||
|
||||
const { results: settings, unsubscribe } = await useQuery('settings', triplit, triplit.query('settings').Where('userId', '=', user.value!.id));
|
||||
const start = Date.now();
|
||||
const remoteSettings = useState<Entity<typeof schema, 'settings'> | null>('user:settings', () => null);
|
||||
const { results } = await useQuery('settings', triplit, triplit.query('settings'))
|
||||
console.log("fetching settings took", Date.now() - start);
|
||||
|
||||
return { settings: computed(() => settings.value![0]!), unsubscribe };
|
||||
}
|
||||
watch(results, (val) => {
|
||||
if (!val) {
|
||||
remoteSettings.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
remoteSettings.value = val[0] as any;
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
// Sync remote settings to local cookies
|
||||
// This bridges the gap between the server state and the local CSS variable application
|
||||
watch(remoteSettings, (newSettings) => {
|
||||
if (!newSettings?.appearance) return;
|
||||
const { appearance } = newSettings;
|
||||
|
||||
if (appearance.colorScheme && appearance.colorScheme !== colorScheme.preference.value) {
|
||||
colorScheme.preference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
|
||||
}
|
||||
|
||||
if (appearance.accent && appearance.accent !== accent.value) {
|
||||
accent.value = appearance.accent;
|
||||
}
|
||||
|
||||
if (appearance.neutral && appearance.neutral !== neutral.value) {
|
||||
neutral.value = appearance.neutral;
|
||||
}
|
||||
|
||||
if (appearance.hinting !== undefined && String(appearance.hinting) !== hinting.value) {
|
||||
hinting.value = String(appearance.hinting);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// Effective settings with defaults for backwards compatibility
|
||||
const settings = computed(() => {
|
||||
const remote = remoteSettings.value;
|
||||
return {
|
||||
appearance: {
|
||||
colorScheme: remote?.appearance?.colorScheme ?? colorScheme.preference.value,
|
||||
accent: remote?.appearance?.accent ?? accent.value,
|
||||
neutral: remote?.appearance?.neutral ?? neutral.value,
|
||||
hinting: remote?.appearance?.hinting ?? Number(hinting.value),
|
||||
fontSize: remote?.appearance?.fontSize ?? 'medium',
|
||||
},
|
||||
systemAssistants: {
|
||||
rename: {
|
||||
enabled: remote?.systemAssistants?.rename?.enabled ?? false,
|
||||
prompt: remote?.systemAssistants?.rename?.prompt ?? null,
|
||||
modelId: remote?.systemAssistants?.rename?.modelId ?? null,
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Update settings both locally and on the server
|
||||
*/
|
||||
const updateSettings = async (updates: {
|
||||
appearance?: {
|
||||
colorScheme?: 'light' | 'dark' | 'system';
|
||||
accent?: string;
|
||||
neutral?: string;
|
||||
hinting?: number;
|
||||
fontSize?: string;
|
||||
};
|
||||
systemAssistants?: {
|
||||
rename?: {
|
||||
enabled?: boolean;
|
||||
prompt?: string | null;
|
||||
modelId?: string | null;
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
if (updates.appearance) {
|
||||
if (updates.appearance.colorScheme) colorScheme.preference.value = updates.appearance.colorScheme;
|
||||
if (updates.appearance.accent) accent.value = updates.appearance.accent;
|
||||
if (updates.appearance.neutral) neutral.value = updates.appearance.neutral;
|
||||
if (updates.appearance.hinting !== undefined) hinting.value = String(updates.appearance.hinting);
|
||||
}
|
||||
|
||||
if (!loggedIn.value || !user.value?.id) return;
|
||||
|
||||
const current = remoteSettings.value;
|
||||
if (!current) {
|
||||
await triplit.insert('settings', {
|
||||
userId: user.value.id,
|
||||
appearance: {
|
||||
colorScheme: colorScheme.preference.value,
|
||||
accent: accent.value,
|
||||
neutral: neutral.value,
|
||||
hinting: Number(hinting.value),
|
||||
...(updates.appearance || {})
|
||||
},
|
||||
systemAssistants: {
|
||||
rename: {
|
||||
enabled: updates.systemAssistants?.rename?.enabled ?? true,
|
||||
prompt: updates.systemAssistants?.rename?.prompt ?? null,
|
||||
modelId: updates.systemAssistants?.rename?.modelId ?? null,
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await triplit.update('settings', current.id, (s) => {
|
||||
if (updates.appearance) {
|
||||
s.appearance = {
|
||||
...(s.appearance || {}),
|
||||
...updates.appearance
|
||||
};
|
||||
}
|
||||
if (updates.systemAssistants) {
|
||||
// @ts-expect-error
|
||||
s.systemAssistants = {
|
||||
...(s.systemAssistants || {}),
|
||||
...updates.systemAssistants
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
remoteSettings,
|
||||
updateSettings,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-full w-full grid place-items-center">
|
||||
<div
|
||||
class="max-w-xs p-4 bg-[var(--color-neutral)] border border-solid border-[var(--color-highlight)] w-full rounded-lg">
|
||||
class="max-w-xs p-4 bg-[var(--bg-surface)] border border-solid border-[var(--color-border)] w-full rounded-lg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,14 +9,14 @@
|
||||
|
||||
<style>
|
||||
input {
|
||||
color: var(--color-text);
|
||||
color: var(--text-primary);
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
|
||||
border-radius: calc(var(--spacing) * 1.5);
|
||||
|
||||
width: 100%;
|
||||
background-color: var(--color-input);
|
||||
background-color: var(--bg-container);
|
||||
|
||||
transition-property: color, border, background-color;
|
||||
transition-duration: 300ms;
|
||||
|
||||
+11
-2
@@ -2,17 +2,25 @@
|
||||
import SettingsDialog from '~/components/Settings/Dialog.vue';
|
||||
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
const { colorScheme } = useTheme();
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
class: colorScheme.class
|
||||
}
|
||||
});
|
||||
|
||||
useKeyboardShortcuts();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidenav />
|
||||
<main
|
||||
class="bg-[var(--color-neutral)] flex flex-col h-full w-full border border-solid border-[var(--color-highlight)] rounded-lg overflow-hidden">
|
||||
class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
|
||||
<div class="h-14 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||
class="flex p-2 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors">
|
||||
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
|
||||
<Icon class="text-5" name="mynaui:panel-left-open" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -26,4 +34,5 @@ useKeyboardShortcuts();
|
||||
</div>
|
||||
</main>
|
||||
<SettingsDialog />
|
||||
<Dropdown />
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const route = useRoute();
|
||||
const inputValue = ref('');
|
||||
const pendingMessage = ref<Message | null>(null);
|
||||
|
||||
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string);
|
||||
@@ -49,11 +52,38 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
|
||||
const topic = await createTopic();
|
||||
if (!topic) throw new Error('Failed to create topic');
|
||||
|
||||
autoRename(topic.id, message);
|
||||
autoRename(topic.id, message).then(async res => {
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to auto-rename:', res.error);
|
||||
await triplit.update('topics', topic.id, {
|
||||
renaming: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
|
||||
|
||||
return sendMessage(message, topic, [], agent.value!, model.provider, model);
|
||||
return sendMessage(message, topic, [], agent.value!, model.provider, model).then(async res => {
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to send message:', res.error);
|
||||
|
||||
pendingMessage.value = null;
|
||||
|
||||
await navigateTo(`/agent/${route.params.id}`);
|
||||
await triplit.delete('topics', topic.id);
|
||||
|
||||
const chatInput = document.getElementById('chat') as HTMLInputElement;
|
||||
if (chatInput) {
|
||||
chatInput.value = message;
|
||||
chatInput.dispatchEvent(new Event('input'));
|
||||
nextTick(() => {
|
||||
chatInput.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -64,28 +94,22 @@ onUnmounted(() => {
|
||||
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<!-- chat pane -->
|
||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||
<div class="flex-grow w-full flex justify-center">
|
||||
<div class="flex h-full max-w-4xl w-full flex-col gap-2"
|
||||
:class="pendingMessage === null ? 'justify-end' : ''">
|
||||
<div v-if="pendingMessage === null">
|
||||
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
|
||||
<p class="mb-28 text-[var(--color-muted)]">Select a topic to continue or create a new one</p>
|
||||
</div>
|
||||
<div class="opacity-70" v-else>
|
||||
<Message :message="pendingMessage" />
|
||||
</div>
|
||||
<div
|
||||
class="chat-scroll-container justify-center w-full h-full [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center">
|
||||
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
|
||||
<div class="w-full px-px flex flex-col flex-grow gap-2"
|
||||
:class="pendingMessage === null ? 'justify-end pb-28' : 'pb-9'">
|
||||
<div v-if="pendingMessage === null">
|
||||
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
|
||||
<p class="text-[var(--text-secondary)]">Select a topic to continue or create a new one</p>
|
||||
</div>
|
||||
<div v-else class="opacity-70">
|
||||
<Message :message="pendingMessage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
|
||||
:providers="providers.filter(p => p.enabled)" @submit="handleSubmit"></ChatInput>
|
||||
</div>
|
||||
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
||||
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:agent="agent" :providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,13 +49,13 @@ onUnmounted(() => {
|
||||
<Icon v-else name="mynaui:check-hexagon" class="text-16" />
|
||||
</div>
|
||||
<input @input="handleInput" placeholder="Agent Name..."
|
||||
class="placeholder:text-[var(--color-highlight)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-highlight-high)] text-12 p-0"
|
||||
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
|
||||
type="text" :value="agent?.name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full h-full mb-14">
|
||||
<label class="text-sm text-[var(--color-text-subtle)]">System Message</label>
|
||||
<label class="text-sm text-[var(--text-secondary)]">System Message</label>
|
||||
<textarea placeholder="You are a helpful assistant."
|
||||
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-highlight)]"
|
||||
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
|
||||
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message, MessageEntity } from '~/composables/useChat';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const rootStart = Date.now();
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const chatPane = ref<HTMLElement | null>(null);
|
||||
const chatPaneWrapper = ref<HTMLElement | null>(null);
|
||||
const inputValue = ref('');
|
||||
const route = useRoute();
|
||||
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
|
||||
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
||||
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels();
|
||||
const { providers, allModels, unsubscribe: unsubscribeModels } = await useModels();
|
||||
|
||||
const agent = computed(() => {
|
||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||
@@ -59,39 +62,42 @@ const [
|
||||
]);
|
||||
|
||||
const topic = computed(() => {
|
||||
if (!rawMessages.value || !rawTopic.value || !rawTopic.value[0]) return null;
|
||||
if (!rawMessages.value || !rawTopic.value?.[0]) return null;
|
||||
|
||||
// Build the messages tree manually for maximum performance
|
||||
const messagesMap = new Map();
|
||||
const partsByMessage = new Map<string, Entity<typeof schema, 'message_parts'>[]>();
|
||||
|
||||
// First pass: Create message objects with parts arrays
|
||||
for (const msg of rawMessages.value) {
|
||||
messagesMap.set(msg.id, {
|
||||
...msg,
|
||||
parts: [],
|
||||
children: [],
|
||||
generation: rawGenerations.value?.find(g => g.id === msg.generationId) ?? null
|
||||
});
|
||||
}
|
||||
|
||||
// Second pass: Attach parts to messages
|
||||
// Group parts by message ID once
|
||||
if (rawParts.value) {
|
||||
for (const part of rawParts.value) {
|
||||
const msg = messagesMap.get(part.messageId);
|
||||
if (msg) {
|
||||
// Filter empty parts here if needed, or just push
|
||||
if (part.content !== '' || part.toolCall !== null) {
|
||||
msg.parts.push(part);
|
||||
}
|
||||
if (!partsByMessage.has(part.messageId)) {
|
||||
partsByMessage.set(part.messageId, []);
|
||||
}
|
||||
if (part.content !== '' || part.toolCall !== null) {
|
||||
partsByMessage.get(part.messageId)!.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: Build children relationships
|
||||
const rootMessages = [];
|
||||
const generationsMap = new Map(
|
||||
rawGenerations.value?.map(g => [g.id, g]) ?? []
|
||||
);
|
||||
|
||||
// Single pass to build messages
|
||||
for (const msg of rawMessages.value) {
|
||||
messagesMap.set(msg.id, {
|
||||
...msg,
|
||||
parts: partsByMessage.get(msg.id) ?? [],
|
||||
children: [],
|
||||
generation: generationsMap.get(msg.generationId!) ?? null
|
||||
});
|
||||
}
|
||||
|
||||
// Build tree
|
||||
const rootMessages: Message[] = [];
|
||||
for (const msg of messagesMap.values()) {
|
||||
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
|
||||
messagesMap.get(msg.parentMessageId).children.push(msg);
|
||||
messagesMap.get(msg.parentMessageId)!.children.push(msg);
|
||||
} else {
|
||||
rootMessages.push(msg);
|
||||
}
|
||||
@@ -104,26 +110,7 @@ const topic = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
if (!topic.value) navigateTo(`/agent/${route.params.id}`);
|
||||
|
||||
const activeGeneration = computed(() => {
|
||||
if (topic.value === null) return null;
|
||||
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
|
||||
});
|
||||
|
||||
const { scrollToBottom } = useAutoScroll(chatPane);
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
|
||||
const handleCancel = async () => {
|
||||
await $fetch(`/api/chat/cancel/${activeGeneration.value!.id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
|
||||
const submitMessage = async (message: string, model: ModelWithProvider | null) => {
|
||||
if (!model) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
@@ -132,6 +119,14 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
|
||||
const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
|
||||
if (!res.ok) {
|
||||
console.error('Failed to send message:', res.error);
|
||||
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
|
||||
console.log("chat input", chatInput, message);
|
||||
if (chatInput) {
|
||||
inputValue.value = message;
|
||||
nextTick(() => {
|
||||
chatInput.focus();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,7 +134,7 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
|
||||
};
|
||||
|
||||
const focusedMessageTree = computed(() => {
|
||||
const tree: MessageEntity[] = [];
|
||||
const tree: Readonly<MessageEntity>[] = [];
|
||||
for (const message of topic.value?.messages || []) {
|
||||
if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
|
||||
if (message.focusedIndex === 0) {
|
||||
@@ -156,7 +151,7 @@ const focusedMessageTree = computed(() => {
|
||||
})
|
||||
|
||||
const handleRegenerate = async (message: Message) => {
|
||||
if (!agent.value.defaultModelId) {
|
||||
if (!agent.value!.defaultModelId) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
}
|
||||
@@ -172,7 +167,7 @@ const handleRegenerate = async (message: Message) => {
|
||||
messageId = message.id;
|
||||
}
|
||||
|
||||
const model = allModels.value.find(m => m.id === agent.value.defaultModelId);
|
||||
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
|
||||
if (!model) {
|
||||
console.error('Model not found');
|
||||
return;
|
||||
@@ -254,37 +249,55 @@ const handleDelete = async (rootMessage: Message) => {
|
||||
});
|
||||
}
|
||||
|
||||
const activeGeneration = computed(() => {
|
||||
if (topic.value === null) return null;
|
||||
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
|
||||
});
|
||||
|
||||
const { scrollToBottom } = useAutoScroll(chatPaneWrapper);
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
|
||||
const handleCancel = async () => {
|
||||
await $fetch(`/api/chat/cancel/${activeGeneration.value?.id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
console.log("full page render took", Date.now() - rootStart);
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeTopic?.();
|
||||
unsubscribeAgents?.();
|
||||
unsubscribeMessages?.();
|
||||
unsubscribeParts?.();
|
||||
unsubscribeGenerations?.();
|
||||
unsubscribeAgents?.();
|
||||
unsubscribeModels?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<!-- chat pane -->
|
||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||
<div class="flex-grow w-full flex justify-center">
|
||||
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9"
|
||||
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
||||
<Message v-for="message in topic.messages" @delete="handleDelete(message)" :key="message.id"
|
||||
@regenerate="handleRegenerate(message)" :message="message" />
|
||||
</div>
|
||||
<!-- chat pane -->
|
||||
<div ref="chatPaneWrapper"
|
||||
class="chat-scroll-container justify-center w-full h-full [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center">
|
||||
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
|
||||
<div class="w-full px-px flex flex-col flex-grow gap-2 pb-9">
|
||||
<Suspense>
|
||||
<template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
||||
<Message v-for="message in topic.messages" :key="message.id" :message="message"
|
||||
v-memo="[message.id, message.parts?.length, message.children, message.focusedIndex, message.content]"
|
||||
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" />
|
||||
</template>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:loading="activeGeneration !== null" :agent="agent"
|
||||
:providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" @cancel="handleCancel">
|
||||
</ChatInput>
|
||||
</div>
|
||||
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
||||
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:loading="activeGeneration !== null" :agent="agent" :providers="providers?.filter(p => p.enabled)"
|
||||
@submit="submitMessage" @cancel="handleCancel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
+32
-27
@@ -3,6 +3,8 @@ import type schema from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import { assert } from '~~/utils/assert';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
|
||||
const { providers, unsubscribe: unsubscribeModels, getFirstAvailableModel, allModels } = await useModels();
|
||||
|
||||
@@ -108,9 +110,34 @@ const handleChatSubmit = async (message: string, model: ModelWithProvider | null
|
||||
|
||||
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`);
|
||||
|
||||
autoRename(topic.id, message);
|
||||
autoRename(topic.id, message).then(async res => {
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to auto-rename:', res.error);
|
||||
await triplit.update('topics', topic.id, {
|
||||
renaming: false,
|
||||
});
|
||||
|
||||
return sendMessage(message, topic, [], agent, model.provider, model);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return sendMessage(message, topic, [], agent, model.provider, model).then(async res => {
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to send message:', res.error);
|
||||
await navigateTo(`/agent/${agent.id}`);
|
||||
await triplit.delete('topics', topic.id);
|
||||
|
||||
const chatInput = document.getElementById('chat') as HTMLInputElement;
|
||||
|
||||
if (chatInput) {
|
||||
chatInput.value = message;
|
||||
chatInput.dispatchEvent(new Event('input'));
|
||||
nextTick(() => {
|
||||
chatInput.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -142,7 +169,9 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
|
||||
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span class="cursor"> </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"> </span>
|
||||
</h1>
|
||||
<div class="max-w-4xl h-full w-full">
|
||||
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
|
||||
@@ -150,27 +179,3 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 0.9em;
|
||||
background-color: currentColor;
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,7 @@ export default defineNuxtPlugin({
|
||||
|
||||
assert('updateOptions' in triplit);
|
||||
triplit.updateOptions({
|
||||
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||
token: session.value.token,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
.use(remarkGfm)
|
||||
.use(remarkMath)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeKatex);
|
||||
.use(rehypeKatex, { output: 'mathml' });
|
||||
|
||||
return {
|
||||
provide: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
export interface DropdownItem {
|
||||
id?: string;
|
||||
active?: boolean;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick?: () => void;
|
||||
value?: string | number;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export const providerBaseUrls = {
|
||||
cerebras: 'https://api.cerebras.ai/v1',
|
||||
google: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
longcat: 'https://api.longcat.chat/openai/v1',
|
||||
cohere: 'https://api.cohere.ai/v1',
|
||||
cohere: 'https://api.cohere.ai/v2',
|
||||
};
|
||||
|
||||
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> };
|
||||
|
||||
@@ -9,6 +9,7 @@ export const hash = async (text: string) => {
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
// simple djb2 hash
|
||||
export const hashSync = (text: string) => {
|
||||
let hash = 5381;
|
||||
let i = 0;
|
||||
|
||||
+29
-13
@@ -1,25 +1,34 @@
|
||||
import {
|
||||
LogoOpenAI,
|
||||
LogoOpenrouter,
|
||||
LogoOllama,
|
||||
LogoCerebras,
|
||||
LogoLongCat,
|
||||
LogoGoogle,
|
||||
LogoGrok,
|
||||
LogoGemini,
|
||||
LogoGemma,
|
||||
LogoDeepMind,
|
||||
LogoVertexAI,
|
||||
LogoDeepCogito,
|
||||
LogoQwen,
|
||||
LogoOpenAI,
|
||||
LogoArcee,
|
||||
LogoAi2,
|
||||
LogoOpenrouter,
|
||||
LogoMoonshot,
|
||||
LogoZAI,
|
||||
LogoGLMV,
|
||||
LogoChatGLM,
|
||||
LogoNvidia,
|
||||
LogoGemma,
|
||||
LogoMistral,
|
||||
LogoMeta,
|
||||
LogoPerplexity,
|
||||
LogoDeepCogito,
|
||||
LogoDeepSeek,
|
||||
LogoClaude,
|
||||
LogoAnthropic,
|
||||
LogoMorph,
|
||||
LogoNousResearch,
|
||||
LogoAi21,
|
||||
LogoWenxin,
|
||||
LogoBaiduCloud,
|
||||
LogoMinimax,
|
||||
LogoNova,
|
||||
@@ -31,24 +40,18 @@ import {
|
||||
LogoXiaomiMiMo,
|
||||
LogoRelace,
|
||||
LogoEssentialAI,
|
||||
LogoClaude,
|
||||
LogoKwaipilot,
|
||||
LogoIBM,
|
||||
LogoInternLM,
|
||||
LogoLongCat,
|
||||
LogoWenxin,
|
||||
LogoHunyuan,
|
||||
LogoInception,
|
||||
LogoAya,
|
||||
LogoCohere,
|
||||
LogoAionLabs,
|
||||
LogoMicrosoft,
|
||||
LogoInflection,
|
||||
LogoAya,
|
||||
LogoDeepMind,
|
||||
LogoVertexAI,
|
||||
LogoGoogle
|
||||
} from '#components';
|
||||
import NousResearch from '~/components/Logo/NousResearch.vue';
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
interface ModelConfig {
|
||||
Icon: any;
|
||||
@@ -210,7 +213,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
|
||||
keywords: [/^morph-/, /\/morph-/]
|
||||
},
|
||||
{
|
||||
Icon: markRaw(NousResearch),
|
||||
Icon: markRaw(LogoNousResearch),
|
||||
keywords: [/deephermes/, /hermes/, /genstruct/, /minos/]
|
||||
},
|
||||
{
|
||||
@@ -311,6 +314,19 @@ const MODEL_MAPPINGS: ModelConfig[] = [
|
||||
}
|
||||
];
|
||||
|
||||
export const providerIcons: Record<string, any | null> = {
|
||||
ollama: markRaw(LogoOllama),
|
||||
google: markRaw(LogoGoogle),
|
||||
openai: markRaw(LogoOpenAI),
|
||||
azure: null,
|
||||
anthropic: null,
|
||||
cohere: markRaw(LogoCohere),
|
||||
huggingface: null,
|
||||
openrouter: markRaw(LogoOpenrouter),
|
||||
cerebras: markRaw(LogoCerebras),
|
||||
longcat: markRaw(LogoLongCat),
|
||||
};
|
||||
|
||||
export function getModelConfig(modelId: string) {
|
||||
const cleanId = modelId.toLowerCase();
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export const initSettings = async (userId: string) => {
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
console.log(userId);
|
||||
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
|
||||
console.log(settings);
|
||||
|
||||
if (!settings) {
|
||||
console.log('no settings');
|
||||
|
||||
@@ -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')),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user