streaming, markdown, model selecting, and lots more
This commit is contained in:
+141
-33
@@ -1,38 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch, computed } from 'vue';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import { schema } from '#triplit/schema';
|
||||
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
let tempInput = '';
|
||||
const inputValue = ref('');
|
||||
const isFocused = ref(false);
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [value: string];
|
||||
submit: [value: string, model: ModelWithProvider | null];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
const props = defineProps<{
|
||||
loading?: boolean;
|
||||
agent?: Entity<typeof schema, 'agents'>;
|
||||
providers?: ProviderWithModels[];
|
||||
}>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (inputValue.value.trim()) {
|
||||
emit('submit', inputValue.value);
|
||||
inputValue.value = '';
|
||||
// Model selection state
|
||||
const selectedModel = ref<ModelWithProvider | null>(null);
|
||||
|
||||
// Get all available models from all providers
|
||||
const allModels = computed(() => {
|
||||
if (!props.providers) return [];
|
||||
return props.providers.flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
...model,
|
||||
provider,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
// Initialize model selection based on agent's defaultModelId or first available
|
||||
const initializeModel = () => {
|
||||
if (selectedModel.value) return;
|
||||
if (!props.providers || !props.agent) return;
|
||||
|
||||
// Try to use agent's default model
|
||||
if (props.agent.defaultModelId) {
|
||||
const agentModel = allModels.value.find((m) => m.id === props.agent!.defaultModelId);
|
||||
if (agentModel) {
|
||||
selectedModel.value = agentModel;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Update agent's default model in Triplit
|
||||
const updateAgentDefaultModel = async (modelId: string) => {
|
||||
if (!props.agent) return;
|
||||
try {
|
||||
await triplit.update('agents', props.agent.id, (agent) => {
|
||||
agent.defaultModelId = modelId;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update agent default model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for model changes and persist to agent
|
||||
watch(selectedModel, (newModel) => {
|
||||
if (newModel && props.agent && newModel.id !== props.agent.defaultModelId) {
|
||||
updateAgentDefaultModel(newModel.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize when providers change
|
||||
watch(() => props.providers, initializeModel, { immediate: true });
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (props.loading) {
|
||||
emit('cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.value.trim()) {
|
||||
emit('submit', inputValue.value, selectedModel.value);
|
||||
inputValue.value = '';
|
||||
}
|
||||
// Reset height after sending
|
||||
if (inputRef.value) inputRef.value.style.height = 'auto';
|
||||
};
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) return;
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (inputRef.value === null) return;
|
||||
|
||||
// inset new line
|
||||
let cursorPosition = inputRef.value.selectionStart;
|
||||
const cursorPosition = inputRef.value.selectionStart;
|
||||
if (cursorPosition === undefined) return;
|
||||
if (cursorPosition !== inputRef.value.selectionEnd) return;
|
||||
|
||||
inputValue.value = inputValue.value.slice(0, cursorPosition) + "\n" + inputValue.value.slice(cursorPosition);
|
||||
// inputRef.value.selectionStart = cursorPosition + 1;
|
||||
inputValue.value =
|
||||
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
|
||||
await nextTick();
|
||||
handleInput();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -40,41 +114,75 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const textarea = inputRef.value;
|
||||
if (!textarea) return;
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const lineHeight = 24;
|
||||
const maxLines = 10;
|
||||
const maxHeight = maxLines * lineHeight;
|
||||
|
||||
const newHeight = textarea.scrollHeight;
|
||||
|
||||
if (newHeight > maxHeight) {
|
||||
textarea.style.height = `${maxHeight}px`;
|
||||
} else {
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
let hasCommandKey = false;
|
||||
if (import.meta.server) {
|
||||
let headers = useRequestHeaders();
|
||||
const headers = useRequestHeaders();
|
||||
hasCommandKey = headers['user-agent']?.includes('Mac OS') ?? false;
|
||||
} else {
|
||||
hasCommandKey = navigator.userAgent.includes('Mac OS');
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
inputValue.value = tempInput;
|
||||
handleInput();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-full mx-auto">
|
||||
<div class="relative flex flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--color-input)]
|
||||
<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="flex-1 min-w-0">
|
||||
<textarea v-model="inputValue" ref="inputRef"
|
||||
<div class="flex-1 min-w-0 max-h-full">
|
||||
<!-- Grammarly literally breaks everything, go fuck yourself -->
|
||||
<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.`"
|
||||
@focus="isFocused = true" @blur="isFocused = false" @keydown="handleKeyDown"
|
||||
class="w-full bg-transparent text-[var(--color-text)] placeholder-white/50 resize-none outline-none text-[15px] leading-6 min-h-[24px] max-h-32 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
|
||||
rows="2"></textarea>
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex">
|
||||
<div class="flex-1"></div>
|
||||
<!-- Send Button -->
|
||||
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() || loading"
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<ModelSelector v-if="providers && providers.length > 0" v-model="selectedModel"
|
||||
:providers="providers"></ModelSelector>
|
||||
</div>
|
||||
<!-- Send/Stop Button -->
|
||||
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
|
||||
:class="[
|
||||
'p-2 rounded-xl transition-all duration-200 flex items-center justify-center',
|
||||
inputValue.trim()
|
||||
'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)]'
|
||||
: 'bg-[var(--color-highlight)] text-[var(--color-highlight-high)] cursor-not-allowed'
|
||||
: 'text-[var(--color-highlight-high)]',
|
||||
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]',
|
||||
]">
|
||||
<Icon v-if="loading" name="svg-spinners:ring-resize" class="w-4 h-4" />
|
||||
<Icon v-else name="mynaui:send-solid" class="w-4 h-4" />
|
||||
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
|
||||
<Icon v-else name="mynaui:send-solid" class="text-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-35
@@ -2,71 +2,66 @@
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
interface Props {
|
||||
items: DropdownItem[];
|
||||
modelValue?: boolean;
|
||||
items?: DropdownItem[];
|
||||
placement?: 'right' | 'left' | 'center';
|
||||
verticality?: 'asscending' | 'descending';
|
||||
width?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
placement: 'right',
|
||||
verticality: 'descending',
|
||||
width: 'auto'
|
||||
})
|
||||
width: 'auto',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'select', item: DropdownItem): void;
|
||||
}>()
|
||||
const emit = defineEmits<(e: 'select', item: DropdownItem) => void>();
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = defineModel<boolean>({ required: true })
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const isOpen = defineModel<boolean>({ required: false });
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const select = (item: DropdownItem) => {
|
||||
if (item.disabled || item.divider) return
|
||||
emit('select', item)
|
||||
isOpen.value = false
|
||||
}
|
||||
if (item.disabled || item.divider) return;
|
||||
emit('select', item);
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const placementClasses = computed(() => {
|
||||
let classes = ''
|
||||
let classes = '';
|
||||
|
||||
switch (props.placement) {
|
||||
case 'right':
|
||||
classes += 'right-0 '
|
||||
break
|
||||
classes += 'right-0 ';
|
||||
break;
|
||||
case 'left':
|
||||
classes += 'left-0 '
|
||||
break
|
||||
classes += 'left-0 ';
|
||||
break;
|
||||
case 'center':
|
||||
classes += 'left-1/2 -translate-x-1/2 '
|
||||
break
|
||||
classes += 'left-1/2 -translate-x-1/2 ';
|
||||
break;
|
||||
default:
|
||||
classes += 'left-0 '
|
||||
break
|
||||
classes += 'left-0 ';
|
||||
break;
|
||||
}
|
||||
|
||||
switch (props.verticality) {
|
||||
case 'asscending':
|
||||
classes += 'bottom-full mb-1.5'
|
||||
break
|
||||
classes += 'bottom-full mb-1.5';
|
||||
break;
|
||||
case 'descending':
|
||||
classes += 'top-full mt-1.5'
|
||||
break
|
||||
classes += 'top-full mt-1.5';
|
||||
break;
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
return classes;
|
||||
});
|
||||
|
||||
useClickOutside(triggerRef, () => {
|
||||
isOpen.value = false
|
||||
})
|
||||
isOpen.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,7 +83,7 @@ useClickOutside(triggerRef, () => {
|
||||
:class="[
|
||||
item.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-[var(--color-highlight)] cursor-pointer'
|
||||
: '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>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { hasTasks } = useTasks()
|
||||
const { open } = useSettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<Teleport :to="open ? '#settings-loader-target' : '#primary-loader-target'">
|
||||
<Icon name="svg-spinners:ring-resize"
|
||||
:class="['text-[var(--color-accent)] text-4', hasTasks ? 'opacity-100' : 'opacity-0']" />
|
||||
</Teleport>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { useFillIds } from '~/composables/useFillIds';
|
||||
|
||||
defineProps<{
|
||||
size?: string | number;
|
||||
color?: boolean;
|
||||
avatar?: boolean;
|
||||
}>();
|
||||
|
||||
const TITLE = 'Gemini';
|
||||
|
||||
const [a, b, c] = useFillIds(TITLE, 3);
|
||||
|
||||
const BACKGROUND_COLOR = "#fff";
|
||||
|
||||
const d = "M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="[`max-width: ${size}px; max-height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 9999px; padding: 0.25rem;` : '']">
|
||||
<svg v-if="color" class="w-full h-full" style="flex: none; line-height: 1;" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
|
||||
<!-- Base Layer -->
|
||||
<path :d="d" fill="#3186FF" />
|
||||
|
||||
<!-- Gradient Layers -->
|
||||
<path :d="d" :fill="a!.fill" />
|
||||
<path :d="d" :fill="b!.fill" />
|
||||
<path :d="d" :fill="c!.fill" />
|
||||
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" :id="a!.id" x1="7" x2="11" y1="15.5" y2="12">
|
||||
<stop stop-color="#08B962" />
|
||||
<stop offset="1" stop-color="#08B962" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient gradientUnits="userSpaceOnUse" :id="b!.id" x1="8" x2="11.5" y1="5.5" y2="11">
|
||||
<stop stop-color="#F94543" />
|
||||
<stop offset="1" stop-color="#F94543" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient gradientUnits="userSpaceOnUse" :id="c!.id" x1="3.5" x2="17.5" y1="13.5" y2="12">
|
||||
<stop stop-color="#FABC12" />
|
||||
<stop offset=".46" stop-color="#FABC12" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg v-else fill="currentColor" fillRule="evenodd" :height="size" style="flex: none; line-height: 1;"
|
||||
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
<path
|
||||
d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
size?: string | number;
|
||||
color?: boolean;
|
||||
avatar?: boolean;
|
||||
}>();
|
||||
|
||||
const TITLE = 'Grok';
|
||||
|
||||
const BACKGROUND_COLOR = "#000";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="[`max-width: ${size}px; max-height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 9999px; padding: 0.25rem;` : '']">
|
||||
<svg class="w-full h-full" fill="currentColor" fillRule="evenodd" style="flex: none; line-height: 1;"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
<path
|
||||
d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
import type { RootContent } from 'hast';
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
finished: boolean;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
let activeIdx = 0;
|
||||
let partIdx = [0];
|
||||
|
||||
if (import.meta.client && hastParts.value && hastParts.value.length > 0 && !props.finished) {
|
||||
const initialParts = splitMarkdown(props.content);
|
||||
|
||||
activeIdx = Math.max(0, initialParts.length - 1);
|
||||
|
||||
let currentOffset = 0;
|
||||
for (let i = 0; i < initialParts.length; i++) {
|
||||
partIdx[i] = currentOffset;
|
||||
|
||||
const tempAst = await partseAst(initialParts[i]!);
|
||||
currentOffset += tempAst.children.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const parts = computed(() => {
|
||||
return splitMarkdown(props.content);
|
||||
})
|
||||
|
||||
watch(parts, async (newParts) => {
|
||||
if (!hastParts.value) hastParts.value = [];
|
||||
|
||||
while (activeIdx < newParts.length - 1) {
|
||||
const finalHast = await partseAst(newParts[activeIdx]!);
|
||||
|
||||
const base: RootContent[] = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = base.concat(finalHast.children);
|
||||
|
||||
partIdx[activeIdx + 1] = hastParts.value!.length;
|
||||
activeIdx++;
|
||||
}
|
||||
|
||||
const currentString = newParts[activeIdx];
|
||||
if (currentString !== undefined) {
|
||||
const latestHast = await partseAst(currentString);
|
||||
|
||||
const stableBase = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = stableBase.concat(latestHast.children);
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-wrapper">
|
||||
<article class="markdown-body">
|
||||
<MarkdownAstNode v-for="(node, index) in hastParts" :key="`${id}-${index}`" :node="node" />
|
||||
</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;
|
||||
}
|
||||
|
||||
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: calc(var(--spacing) * 4) 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: min-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import { hashSync } from '~/utils/hash';
|
||||
const props = defineProps<{ code: string; lang: string }>();
|
||||
|
||||
const renderId = hashSync(props.code + props.lang);
|
||||
|
||||
const { data: html } = useAsyncData<string>(`shiki-${renderId}`, async () => parseCode());
|
||||
const lineNumberWidth = computed(() => {
|
||||
if (!html.value) return 1;
|
||||
// Count newlines in the generated HTML or the source code
|
||||
// Using props.code is safer and faster than parsing the HTML string
|
||||
return props.code.split('\n').length.toString().length;
|
||||
});
|
||||
|
||||
watch(() => props.code, async () => {
|
||||
html.value = await parseCode();
|
||||
});
|
||||
|
||||
async function parseCode() {
|
||||
const shiki = await getShikiHighlighter();
|
||||
let lang = props.lang.toLowerCase();
|
||||
try {
|
||||
shiki.getLanguage(lang);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
return shiki.codeToHtml(props.code.trim(), {
|
||||
lang,
|
||||
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl overflow-hidden code-container" :style="`--line-number-width: ${lineNumberWidth}ch`"
|
||||
:id="`code-${renderId}`" v-html="html">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.code-container {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-container>pre {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 1rem;
|
||||
line-height: 1.625;
|
||||
counter-reset: lines;
|
||||
}
|
||||
|
||||
.code-container>pre>code .line::before {
|
||||
counter-increment: lines;
|
||||
content: counter(lines);
|
||||
width: var(--line-number-width);
|
||||
margin-right: 1.5rem;
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dark .code-container>pre>code .line::before {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.light .code-container>pre>code .line::before {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '~~/types';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
regenerations?: Message[];
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
regenerations: () => [],
|
||||
isCurrent: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
regenerate: [messageId: string];
|
||||
select: [messageId: string];
|
||||
delete: [messageId: string];
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const content = computed(() => props.message.content)
|
||||
const hasAlternatives = computed(() => props.regenerations.length > 0)
|
||||
const showDropdown = computed(() => hasAlternatives.value || !props.isUser)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-3 relative group">
|
||||
<div
|
||||
class="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
:class="isUser ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-neutral)] border border-[var(--color-highlight)]'"
|
||||
>
|
||||
<Icon
|
||||
:name="isUser ? 'mynaui:user' : 'mynaui:check-hexagon'"
|
||||
class="w-4 h-4"
|
||||
:class="isUser ? 'text-[var(--color-accent-text)]' : 'text-[var(--color-accent)]'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<p class="text-sm font-medium" :class="isUser ? 'text-[var(--color-accent)]' : 'text-[var(--color-neutral)]'">
|
||||
{{ isUser ? 'You' : 'Agent' }}
|
||||
</p>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Dropdown v-if="showDropdown" v-model="isOpen" placement="bottom-right" width="140px">
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
@click="toggle"
|
||||
class="p-1 rounded hover:bg-[var(--color-highlight)]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<Icon name="mynaui:dots-horizontal" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<button
|
||||
v-if="!isUser"
|
||||
@click="emit('regenerate', message.id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
|
||||
>
|
||||
<Icon name="mynaui:refresh" class="w-4 h-4" />
|
||||
<span class="text-sm">Regenerate</span>
|
||||
</button>
|
||||
<div v-if="!isUser && hasAlternatives" class="h-px bg-[var(--color-highlight)] my-1" />
|
||||
<template v-if="hasAlternatives">
|
||||
<button
|
||||
v-for="alt in regenerations"
|
||||
:key="alt.id"
|
||||
@click="emit('select', alt.id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
|
||||
:class="message.id === alt.id ? 'bg-[var(--color-highlight)]' : ''"
|
||||
>
|
||||
<Icon name="mynaui:clock" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||
<span class="text-sm text-[var(--color-subtle)]">{{ alt.id }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="h-px bg-[var(--color-highlight)] my-1" />
|
||||
<button
|
||||
@click="emit('delete', message.id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left text-red-400"
|
||||
>
|
||||
<Icon name="mynaui:trash" class="w-4 h-4" />
|
||||
<span class="text-sm">Delete</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[var(--color-text)] whitespace-pre-wrap break-words">{{ content }}</p>
|
||||
|
||||
<div v-if="editedAt" class="mt-1 text-xs text-[var(--color-subtle)] flex items-center gap-1">
|
||||
<Icon name="mynaui:pencil" class="w-3 h-3" />
|
||||
<span>Edited</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
defineProps<{
|
||||
error_part: Entity<typeof schema, 'message_parts'>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
const props = defineProps<{
|
||||
part: Readonly<Entity<typeof schema, 'message_parts'>>;
|
||||
}>();
|
||||
|
||||
const reasoningOpen = ref(!props.part.finished);
|
||||
|
||||
watch(
|
||||
() => props.part.finished,
|
||||
() => {
|
||||
reasoningOpen.value = !props.part.finished;
|
||||
},
|
||||
);
|
||||
|
||||
const containerRef: Ref<HTMLDivElement | null> = ref(null);
|
||||
const scrollState = ref('middle');
|
||||
|
||||
const { scrollToBottom } = useAutoScroll(containerRef);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const container = containerRef.value;
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
|
||||
const topThreshold = 0.02 * clientHeight;
|
||||
|
||||
if (scrollTop <= topThreshold) {
|
||||
scrollState.value = 'top';
|
||||
} else if (scrollTop + 100 >= scrollHeight - clientHeight) {
|
||||
scrollState.value = 'bottom';
|
||||
} else {
|
||||
scrollState.value = 'middle';
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReasoning = async () => {
|
||||
if (!props.part.finished) return;
|
||||
|
||||
reasoningOpen.value = !reasoningOpen.value;
|
||||
if (!reasoningOpen.value) return;
|
||||
await nextTick();
|
||||
scrollToBottom('instant');
|
||||
handleScroll();
|
||||
};
|
||||
</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>
|
||||
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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
const props = defineProps<{
|
||||
part: Readonly<Entity<typeof schema, 'message_parts'>>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
|
||||
}>();
|
||||
|
||||
const activeTab = ref('input');
|
||||
|
||||
const indicatorStyle = computed(() => {
|
||||
const tabs = ['input', 'output', 'trace'];
|
||||
const index = tabs.indexOf(activeTab.value);
|
||||
// Each tab button is ~48px (40px height + 8px gap)
|
||||
const offset = index * 48;
|
||||
return {
|
||||
transform: `translateY(${offset}px)`,
|
||||
top: '4px',
|
||||
};
|
||||
});
|
||||
|
||||
const shiki = await getShikiHighlighter();
|
||||
|
||||
const html = ref('');
|
||||
const lineNumberWidth = ref(1);
|
||||
|
||||
const input: ComputedRef<string> = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'input':
|
||||
if (props.toolCall.input === null) return '';
|
||||
if (props.toolCall.input!.type === 'json') {
|
||||
return JSON.stringify(JSON.parse(props.toolCall.input!.value), null, 2);
|
||||
}
|
||||
|
||||
return props.toolCall.input!.value;
|
||||
case 'output':
|
||||
if (props.toolCall.output === null) return '';
|
||||
if (props.toolCall.output!.type === 'json') {
|
||||
return JSON.stringify(JSON.parse(props.toolCall.output!.value), null, 2);
|
||||
}
|
||||
|
||||
return props.toolCall.output!.value;
|
||||
case 'trace': {
|
||||
const traceObj: any = { ...props.toolCall };
|
||||
if (traceObj === null) return '';
|
||||
// marshall the trace object and the input, output, and error into their correct types
|
||||
switch (traceObj.input?.type) {
|
||||
case 'text':
|
||||
traceObj.input = traceObj.input.value;
|
||||
break;
|
||||
case 'json':
|
||||
traceObj.input = JSON.parse(traceObj.input.value);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (traceObj.output?.type) {
|
||||
case 'text':
|
||||
traceObj.output = traceObj.output.value;
|
||||
break;
|
||||
case 'json':
|
||||
traceObj.output = JSON.parse(traceObj.output.value);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (traceObj.error?.type) {
|
||||
case 'text':
|
||||
traceObj.error = traceObj.error.value;
|
||||
break;
|
||||
case 'json':
|
||||
traceObj.error = JSON.parse(traceObj.error.value);
|
||||
break;
|
||||
}
|
||||
|
||||
return JSON.stringify(traceObj, null, 2);
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
input,
|
||||
(newCode) => {
|
||||
let lang = 'json';
|
||||
try {
|
||||
shiki.getLanguage(lang);
|
||||
} catch (e) {
|
||||
lang = 'text';
|
||||
}
|
||||
|
||||
html.value = shiki.codeToHtml(newCode, {
|
||||
lang,
|
||||
themes: {
|
||||
dark: 'vitesse-dark',
|
||||
light: 'vitesse-light',
|
||||
},
|
||||
});
|
||||
lineNumberWidth.value = html.value.split('\n').length.toString().length;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</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">
|
||||
<button @click="activeTab = 'input'" :class="[
|
||||
'hover:bg-[var(--color-highlight)] p-2 rounded-lg flex gap-1 items-center w-full transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
activeTab === 'input' ? 'text-orange-6' : ''
|
||||
]">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
<Icon name="mynaui:code" class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
Input
|
||||
</button>
|
||||
<button @click="activeTab = 'output'" :class="[
|
||||
'hover:bg-[var(--color-highlight)] p-2 rounded-lg flex gap-1 items-center w-full transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
activeTab === 'output' ? 'text-orange-6' : ''
|
||||
]">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
<Icon name="mynaui:arrow-down-square" class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
Output
|
||||
</button>
|
||||
<button @click="activeTab = 'trace'" :class="[
|
||||
'hover:bg-[var(--color-highlight)] p-2 rounded-lg flex gap-1 items-center w-full transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
activeTab === 'trace' ? 'text-orange-6' : ''
|
||||
]">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
<Icon name="mynaui:flask" class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
Function call
|
||||
</button>
|
||||
|
||||
<div :style="indicatorStyle"
|
||||
class="absolute bg-orange-6 w-[3px] h-8 rounded-l mt-1 right-0 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow overflow-auto max-h-full max-w-full">
|
||||
<div class="overflow-hidden h-full" :style="`--line-number-width: ${lineNumberWidth}ch`"
|
||||
id="function-call-container" v-html="html">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#function-call-container>pre {
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 1rem;
|
||||
line-height: 1.625;
|
||||
counter-reset: lines;
|
||||
}
|
||||
|
||||
#function-call-container>pre>code .line::before {
|
||||
counter-increment: lines;
|
||||
content: counter(lines);
|
||||
width: var(--line-number-width);
|
||||
margin-right: 1.5rem;
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dark #function-call-container>pre>code .line::before {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.light #function-call-container>pre>code .line::before {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import Debug from './Debug.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
|
||||
}>();
|
||||
|
||||
const deubgToolCallOpen = ref(false);
|
||||
|
||||
const toggleDebugToolCall = () => {
|
||||
deubgToolCallOpen.value = !deubgToolCallOpen.value;
|
||||
};
|
||||
|
||||
const iconName = ref('mynaui:tool');
|
||||
const iconColor = ref('var(--color-subtle)');
|
||||
|
||||
watch(
|
||||
() => props.toolCall.status,
|
||||
(status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
iconName.value = 'svg-spinners:180-ring-with-bg';
|
||||
break;
|
||||
case 'completed':
|
||||
iconName.value = 'mynaui:tool';
|
||||
break;
|
||||
case 'failed':
|
||||
iconName.value = 'mynaui:x-solid';
|
||||
iconColor.value = '#ff3b3b';
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<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)]">
|
||||
<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">
|
||||
<Icon :name="iconName" :style="{ color: iconColor }"
|
||||
class="w-3 h-3 text-[var(--color-subtle)]" />
|
||||
</div>
|
||||
{{ toolCall.toolName }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<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)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Debug v-if="deubgToolCallOpen" :toolCall="toolCall" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import ShikiHighlight from '~/components/Markdown/ShikiHighlight.vue';
|
||||
import Reasoning from './Reasoning.vue';
|
||||
import Text from './Text.vue';
|
||||
import Tool from './Tool/index.vue';
|
||||
|
||||
defineProps<{
|
||||
message: Readonly<
|
||||
Entity<typeof schema, 'messages'> & {
|
||||
parts: (Entity<typeof schema, 'message_parts'> & {
|
||||
toolCall: Entity<typeof schema, 'tool_calls'> | null;
|
||||
})[];
|
||||
} & { generation: Entity<typeof schema, 'generations'> | null }
|
||||
>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- <pre class="max-w-full overflow-x-auto">{{ JSON.stringify(message, null, 2) }}</pre> -->
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div v-for="part in message.parts" :key="part.id">
|
||||
<Reasoning v-if="part.type === 'reasoning'" :part="part" />
|
||||
<Text v-else-if="part.type === 'text'" :part="part" />
|
||||
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" />
|
||||
<div v-else>
|
||||
Unhandled part type: {{ part.type }} {{ part }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between text-zinc-400 dark:text-zinc-600 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" />
|
||||
{{ message.generation.modelId }}
|
||||
</span>
|
||||
<span class="flex gap-1 items-center" v-if="message.generation.tokens?.output">
|
||||
<Icon name="tabler:coins" />
|
||||
{{ message.generation?.tokens?.output }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="message.generation?.status === 'pending' && message.parts.length === 0">
|
||||
<span class="text-sm text-[var(--color-muted)] flex flex-row items-center">
|
||||
<Icon name="svg-spinners:pulse-2" class="text-4" />
|
||||
Preparing generating...
|
||||
</span>
|
||||
</span>
|
||||
<div v-else-if="message.generation?.status === 'failed'">
|
||||
<span class="text-sm text-[var(--color-error)]">Generation failed</span>
|
||||
<div class="text-sm">
|
||||
<ShikiHighlight :code="message.generation.error ?? 'An unknown error occurred'" lang="json" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
defineProps<{
|
||||
message: Readonly<Entity<typeof schema, 'messages'>>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--color-highlight)] py-2 px-3 rounded-xl"
|
||||
:content="message.content!" :id="message.id" />
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
defineProps<{
|
||||
message: Readonly<
|
||||
Entity<typeof schema, 'messages'> & {
|
||||
parts: (Entity<typeof schema, 'message_parts'> & {
|
||||
toolCall: Entity<typeof schema, 'tool_calls'> | null;
|
||||
})[];
|
||||
} & { generation: Entity<typeof schema, 'generations'> | null }
|
||||
>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['max-w-full mb-4', message.role === 'user' ? 'pl-9 flex justify-end' : '']">
|
||||
<MessageUser v-if="message.role === 'user'" :message="message" />
|
||||
<MessageAgent v-else :message="message" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { getModelConfig } from '~/utils/model-mapping';
|
||||
|
||||
const props = defineProps<{
|
||||
modelId: string;
|
||||
variant?: 'monochrome' | 'color';
|
||||
size?: string | number;
|
||||
avatar?: boolean;
|
||||
}>();
|
||||
|
||||
const config = computed(() => getModelConfig(props.modelId));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center">
|
||||
<component :is="config.icon" v-if="config.icon" :avatar="avatar" :size="size" :color="variant === 'color'" />
|
||||
<!-- Fallback if no logo matches -->
|
||||
<div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }" class="bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { schema } from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ModelWithProvider | null;
|
||||
providers: ProviderWithModels[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [model: ModelWithProvider | null];
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const dropdownRef = ref<HTMLDivElement | null>(null);
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return props.providers;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return props.providers
|
||||
.map((provider) => ({
|
||||
...provider,
|
||||
models: provider.models.filter((model) =>
|
||||
model.name.toLowerCase().includes(query)
|
||||
),
|
||||
}))
|
||||
.filter((provider) => provider.models.length > 0);
|
||||
});
|
||||
|
||||
const formatContextWindow = (window: number | null | undefined): string => {
|
||||
if (!window) return '';
|
||||
if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`;
|
||||
if (window >= 1000) return `${(window / 1000).toFixed(0)}K`;
|
||||
return window.toString();
|
||||
};
|
||||
|
||||
const hasCapability = (model: Entity<typeof schema, 'models'>, capability: string): boolean => {
|
||||
return model.attributes.capabilities.has(capability);
|
||||
};
|
||||
|
||||
const hasInputModality = (model: Entity<typeof schema, 'models'>, modality: string): boolean => {
|
||||
return model.attributes.inputModalities.has(modality);
|
||||
};
|
||||
|
||||
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
|
||||
selectedModel.value = { ...model, provider };
|
||||
isOpen.value = false;
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useClickOutside(dropdownRef, () => {
|
||||
isOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="dropdownRef" class="relative">
|
||||
<button @click="isOpen = !isOpen"
|
||||
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)]',
|
||||
]">
|
||||
<ModelIcon v-if="selectedModel" :avatar="true" variant="color" :model-id="selectedModel.externalId"
|
||||
size="16" />
|
||||
<Icon v-else name="mynaui:warning-circle" class="text-4" />
|
||||
<span class="max-w-[150px] truncate">
|
||||
{{ selectedModel ? selectedModel.name : 'Select a model' }}
|
||||
</span>
|
||||
<Icon name="mynaui:chevron-down" class="text-3.5 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }" />
|
||||
</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 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-if="isOpen"
|
||||
class="transform-origin-bottom-center absolute bottom-full left-0 mb-2 max-w-[420px] w-full max-h-[460px] flex flex-col rounded-xl border border-[var(--color-highlight)] bg-[var(--color-neutral)] 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)]" />
|
||||
<input ref="searchInputRef" v-model="searchQuery" 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2 select-none">
|
||||
<div v-if="filteredProviders.length === 0"
|
||||
class="px-4 py-8 text-center text-sm text-[var(--color-muted)]">
|
||||
No models found
|
||||
</div>
|
||||
|
||||
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
|
||||
<div class="px-4 py-1.5 text-xs font-medium text-[var(--color-muted)] uppercase tracking-wider">
|
||||
{{ provider.name }}
|
||||
</div>
|
||||
|
||||
<button v-for="model in provider.models.filter(m => m.enabled)" :key="model.id"
|
||||
@click="selectModel(model, provider)"
|
||||
class="w-full px-4 py-2 flex items-center gap-3 hover:bg-[var(--color-highlight-low)] transition-colors duration-150"
|
||||
:class="{ 'bg-[var(--color-highlight-low)]': selectedModel?.id === model.id }">
|
||||
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="20" />
|
||||
|
||||
<span class="flex-1 text-sm text-left text-[var(--color-text)] truncate">
|
||||
{{ model.name }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div v-if="hasInputModality(model, 'image')"
|
||||
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
|
||||
<Icon name="mynaui:image" class="text-2.5 text-emerald" title="Vision" />
|
||||
</div>
|
||||
<div v-if="hasCapability(model, 'reasoning')"
|
||||
class="w-4.5 h-4.5 bg-[color-mix(in_srgb,_transparent_90%,_var(--reasoning-accent)_10%)] rounded flex items-center justify-center">
|
||||
<Icon name="mynaui:atom" class="text-2.5 text-[var(--reasoning-accent)]"
|
||||
title="Reasoning" />
|
||||
</div>
|
||||
<div v-if="hasCapability(model, 'tools')"
|
||||
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
|
||||
<Icon name="mynaui:tool" class="text-2.5 text-sky" title="Tools" />
|
||||
</div>
|
||||
</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)]">
|
||||
{{ formatContextWindow(model.attributes.contextWindow) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-1 border-t border-[var(--color-highlight-low)]">
|
||||
<NuxtLink to="/settings/providers"
|
||||
class="flex 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"
|
||||
@click="isOpen = false">
|
||||
<Icon name="mynaui:cog-four" class="text-4" />
|
||||
<span>Manage Provider</span>
|
||||
<Icon name="mynaui:arrow-right" class="text-3.5 ml-auto" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
||||
import { providerBaseUrls } from '~/types/model';
|
||||
import { useSettings } from '~/composables/useSettings';
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const { pageParams } = useSettings();
|
||||
|
||||
const { providers } = await useModels();
|
||||
|
||||
const provider = computed(() => {
|
||||
if (pageParams.value.length === 0) return null;
|
||||
return providers.value!.find(p => p.id === pageParams.value[0]);
|
||||
});
|
||||
|
||||
watch(provider, async () => {
|
||||
if (!provider.value) return;
|
||||
await decryptApiKey();
|
||||
});
|
||||
|
||||
const apiKeyVisible = ref(false);
|
||||
|
||||
const apiKey = ref('');
|
||||
const apiProxyUrl = ref(provider.value!.config.apiProxyUrl ?? '');
|
||||
const modelSearch = ref('');
|
||||
|
||||
const providerApiUrl = computed(() => apiProxyUrl.value === '' ? providerBaseUrls[provider.value!.type] : apiProxyUrl.value);
|
||||
|
||||
const decryptApiKey = async () => {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
apiKey.value = await decrypt(key, base64ToUint8Array(provider.value!.config.apiKey));
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
await decryptApiKey();
|
||||
};
|
||||
|
||||
const toggleProvider = async () => {
|
||||
await triplit.update('providers', provider.value!.id, {
|
||||
enabled: !provider.value!.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const updateApiKey = async (value: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
const encypted = await encryptData(key, value);
|
||||
|
||||
await triplit.update('providers', provider.value.id, {
|
||||
config: {
|
||||
...provider.value.config,
|
||||
apiKey: uint8ArrayToBase64(encypted),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateProxyUrl = async (value: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
await triplit.update('providers', provider.value.id, {
|
||||
config: {
|
||||
...provider.value.config,
|
||||
apiProxyUrl: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleModel = async (id: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
await triplit.update('models', id, {
|
||||
enabled: !provider.value!.models.find(m => m.id === id)!.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchingModels = ref(false);
|
||||
|
||||
const fetchModels = async () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
fetchingModels.value = true;
|
||||
|
||||
try {
|
||||
const [providerResponse, devDataResponse] = await Promise.all([
|
||||
$fetch(`${providerApiUrl.value}/models`),
|
||||
$fetch('https://models.dev/api.json')
|
||||
]);
|
||||
|
||||
const providerType = provider.value!.type;
|
||||
const modelDetails = devDataResponse[providerType]?.models || {};
|
||||
|
||||
const existingModelsMap = new Map(
|
||||
(provider.value?.models || []).map((m: any) => [m.externalId, m])
|
||||
);
|
||||
|
||||
const toInsert: any[] = [];
|
||||
const toUpdate: { id: string, data: any }[] = [];
|
||||
|
||||
providerResponse.data.forEach((pModel: any) => {
|
||||
const slug = pModel.id.toLowerCase();
|
||||
const info = modelDetails[slug] || {};
|
||||
|
||||
console.log("INFO", info);
|
||||
|
||||
const capabilities = [];
|
||||
|
||||
if (info.reasoning) {
|
||||
capabilities.push('reasoning');
|
||||
}
|
||||
|
||||
if (info.tool_call) {
|
||||
capabilities.push('tools');
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
inputModalities: new Set(info.modalities?.input.filter(m => ['text', 'image'].includes(m)) || ['text']),
|
||||
outputModalities: new Set(info.modalities?.output.filter(m => ['text', 'image'].includes(m)) || ['text']),
|
||||
capabilities,
|
||||
contextWindow: pModel.context_length || info.limit?.context || null,
|
||||
supported_parameters: new Set(pModel.supported_parameters || ["temperature", "max_tokens"]),
|
||||
};
|
||||
|
||||
const existing = existingModelsMap.get(pModel.id);
|
||||
|
||||
if (existing) {
|
||||
// UPDATE logic: Remove 'id' from the payload as per Triplit requirements
|
||||
const { id, ...existingWithoutId } = existing;
|
||||
|
||||
toUpdate.push({
|
||||
id: existing.id,
|
||||
data: {
|
||||
...existingWithoutId,
|
||||
name: existing.name || info.name || pModel.name || pModel.id,
|
||||
attributes: attributes, // Update tech specs
|
||||
releasedAt: new Date(pModel.created * 1000),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// INSERT logic: This is a brand new model
|
||||
toInsert.push({
|
||||
userId: user.value?.id,
|
||||
providerId: provider.value!.id,
|
||||
externalId: pModel.id,
|
||||
name: info.name || pModel.name || pModel.id,
|
||||
isCustom: false,
|
||||
enabled: false,
|
||||
attributes: attributes,
|
||||
releasedAt: new Date(pModel.created * 1000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
...toInsert.map(item => triplit.insert('models', item)),
|
||||
...toUpdate.map(item => triplit.update('models', item.id, (m) => {
|
||||
Object.assign(m, item.data);
|
||||
}))
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
} finally {
|
||||
fetchingModels.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 mt-4">
|
||||
<div class="flex flex-row justify-between gap-16">
|
||||
<label class="whitespace-nowrap" for="provider-api-key">Enabled</label>
|
||||
<Slider :checked="provider!.enabled" @click.stop="toggleProvider()" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
@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)]">
|
||||
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :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)]">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="pt-5 justify-between w-full flex">
|
||||
<h4 class="whitespace-nowrap m-0">
|
||||
Model List
|
||||
<span class="text-sm text-[var(--color-muted)] font-normal text-xs">
|
||||
{{ provider?.models.length }} models available
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="modelSearch" type="text" class="px-2 py-1 bg-[var(--color-highlight)] text-xs"
|
||||
placeholder="Search models..." />
|
||||
|
||||
<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)]">
|
||||
<Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
|
||||
fetch models
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)]">
|
||||
No models found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1 mt-2">
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
Enabled
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="p-3 flex items-center justify-between"
|
||||
v-for="model in provider?.models.filter(m => m.enabled === true).filter(m => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()))"
|
||||
:key="model.id">
|
||||
<div class="flex flex-row items-center">
|
||||
<div class="flex items-center">
|
||||
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="32" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 ml-2">
|
||||
<div
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] flex items-center gap-1">
|
||||
{{ model.name }}
|
||||
<span
|
||||
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)]">
|
||||
{{ model.externalId }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">
|
||||
Released on {{
|
||||
model.releasedAt?.toISOString().split('T')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Slider :checked="model.enabled" @click="toggleModel(model.id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
Disabled
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="p-3 flex items-center justify-between"
|
||||
v-for="model in provider?.models.filter(m => m.enabled === false).filter(m => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()))"
|
||||
:key="model.id">
|
||||
<div class="flex flex-row items-center">
|
||||
<div class="flex items-center">
|
||||
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="32" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 ml-2">
|
||||
<div
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] flex items-center gap-1">
|
||||
{{ model.name }}
|
||||
<span
|
||||
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)]">
|
||||
{{ model.externalId }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">
|
||||
Released on {{ model.releasedAt?.toISOString().split('T')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Slider :checked="model.enabled" @click="toggleModel(model.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.animate-rotate {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import GeneralSettings from './GeneralSettings.vue';
|
||||
import ProviderSettings from './ProviderSettings.vue';
|
||||
import ProviderSidebar from './ProviderSidebar.vue';
|
||||
import AIServiceProvider from './AIServiceProvider.vue';
|
||||
|
||||
const { providers } = await useModels();
|
||||
|
||||
const { currentPage, pageParams, open, setPage, close } = useSettings();
|
||||
|
||||
console.log(providers.value);
|
||||
|
||||
const PAGES_CONFIG = {
|
||||
general: {
|
||||
label: 'General',
|
||||
icon: 'mynaui:cog-four',
|
||||
component: GeneralSettings
|
||||
},
|
||||
providers: {
|
||||
label: 'AI Providers',
|
||||
icon: 'mynaui:api',
|
||||
component: ProviderSettings,
|
||||
sidebar: ProviderSidebar
|
||||
},
|
||||
} as const;
|
||||
|
||||
const runtimePage = computed(() => {
|
||||
// 1. Get the base config (e.g., 'providers' or 'general')
|
||||
const config = PAGES_CONFIG[currentPage.value as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
|
||||
|
||||
// 2. Determine the actual component to show
|
||||
let component = config.component;
|
||||
let label = config.label as string;
|
||||
|
||||
if (currentPage.value === 'providers' && pageParams.value.length > 0) {
|
||||
component = AIServiceProvider;
|
||||
const providerId = pageParams.value[0];
|
||||
console.log("PROVIDERS", providers.value);
|
||||
const provider = providers.value!.find(p => p.id === providerId);
|
||||
label = provider ? provider.name : 'Unknown Provider';
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
label,
|
||||
component,
|
||||
params: pageParams.value,
|
||||
} as {
|
||||
label: string;
|
||||
icon: string;
|
||||
component: Component;
|
||||
sidebar?: Component;
|
||||
params: string[];
|
||||
};
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
document.body.addEventListener('keydown', handleKeyDown);
|
||||
} else {
|
||||
document.body.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (open.value) {
|
||||
document.body.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="open" class="fixed inset-0 z-45 bg-black/80 backdrop-blur-md" @click.self="close">
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
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)]
|
||||
overflow-hidden flex max-h-[90vh] p-2">
|
||||
<nav class="w-64 flex flex-col gap-1 mr-2">
|
||||
<!-- 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']">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<Icon :name="config.icon" class="w-5 h-5" />
|
||||
{{ config.label }}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- DYNAMIC CONTENT -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="flex-1 p-3 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
|
||||
<header class="flex items-center justify-between pl-2 pb-2 ">
|
||||
<h2 class="text-lg font-semibold m-0">{{ 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)]"
|
||||
@click="close">
|
||||
<Icon name="mynaui:x-solid" />
|
||||
</button>
|
||||
</header>
|
||||
<!-- KeepAlive preserves state if the user clicks back/forth between tabs -->
|
||||
<KeepAlive>
|
||||
<component @navigate="setPage" :is="runtimePage.component" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
const triplit = useTriplitClient();
|
||||
const { providers } = await useModels();
|
||||
|
||||
const toggleProvider = async (id: string) => {
|
||||
const provider = providers.value!.find(p => p.id === id);
|
||||
if (!provider) return;
|
||||
|
||||
await triplit.update('providers', provider.id, {
|
||||
enabled: !provider.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<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)]">
|
||||
{{providers?.filter(p => p.enabled).length}}
|
||||
</span>
|
||||
</h2>
|
||||
<div
|
||||
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)]">
|
||||
<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>
|
||||
<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)]" /> -->
|
||||
<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)]">
|
||||
{{providers?.filter(p => !p.enabled).length}}
|
||||
</span>
|
||||
</h2>
|
||||
<div
|
||||
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)]">
|
||||
<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>
|
||||
<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)]" /> -->
|
||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const { pageParams } = useSettings();
|
||||
const { providers } = await useModels();
|
||||
|
||||
console.log("PROVIDERS", providers.value);
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<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)]">
|
||||
<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)]">
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { addTask, completeTask } = useTasks()
|
||||
const { open, currentPage, setPage, close } = useSettings()
|
||||
|
||||
const pages = ['page1', 'page2', 'page3']
|
||||
|
||||
const simulateTask = () => {
|
||||
const handle = addTask()
|
||||
setTimeout(() => {
|
||||
completeTask(handle)
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="open" class="fixed inset-0 z-45 bg-black/80" @click.self="close">
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
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-[70vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
|
||||
overflow-hidden flex max-h-[90vh] p-2">
|
||||
<!-- Sidebar Nav -->
|
||||
<nav class="w-64 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between pb-4">
|
||||
<div class="flex items-center gap-2 px-2">
|
||||
<Icon name="mynaui:cog-four" class="w-7 h-7 text-[var(--color-subtle)] mt-1" />
|
||||
<h1 class="font-semibold text-center">Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="page in pages" :key="page" :class="[
|
||||
'select-none cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left',
|
||||
currentPage === page ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]/10'
|
||||
]" @click="setPage(page)">
|
||||
{{ page.charAt(0).toUpperCase() + page.slice(1) }}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="flex items-center justify-between pl-2 pb-2">
|
||||
<h2 class="text-lg font-semibold">{{ currentPage.charAt(0).toUpperCase() + currentPage.slice(1)
|
||||
}}
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div id="settings-loader-target"></div>
|
||||
<button @click="close"
|
||||
class="p-1.5 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)]/10 transition-colors">
|
||||
<Icon name="mynaui:x-solid" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="flex-1 p-6 ml-1 mt-1 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
|
||||
<div v-if="currentPage === 'page1'">
|
||||
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 1 content. Try adding a
|
||||
task to
|
||||
the queue
|
||||
below.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="accent" @click="simulateTask">
|
||||
Simulate Task (2s)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="currentPage === 'page2'">
|
||||
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 2 content with some
|
||||
details.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 1</div>
|
||||
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="currentPage === 'page3'">
|
||||
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 3 content.</p>
|
||||
<button class="accent" @click="simulateTask">Run Task</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -1,45 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown'
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
import { authClient } from '~~/lib/auth-client';
|
||||
|
||||
const { user, signOut } = useAuth()
|
||||
const { toggle: toggleSettings } = useSettings()
|
||||
const triplit = useTriplitClient();
|
||||
const { user } = await useAuth();
|
||||
|
||||
const hovering = defineModel<boolean>({ required: true })
|
||||
const { toggle: toggleSettings } = useSettings();
|
||||
|
||||
const profileOpen = ref(false)
|
||||
const hovering = defineModel<boolean>({ required: true });
|
||||
|
||||
const profileOpen = ref(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut()
|
||||
}
|
||||
await authClient.signOut();
|
||||
if ('endSession' in triplit) {
|
||||
await triplit.endSession();
|
||||
}
|
||||
|
||||
clearNuxtData();
|
||||
|
||||
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 },
|
||||
]
|
||||
{
|
||||
label: 'Log out',
|
||||
icon: 'mynaui:logout',
|
||||
divider: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center justify-between overflow-hidden">
|
||||
<Dropdown v-model="profileOpen" :items="profileItems" placement="left" verticality="descending" width="100%">
|
||||
<Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
|
||||
verticality="descending" width="100%">
|
||||
<template #trigger="{ toggle }">
|
||||
<div role="button" aria-label="open user dropdown"
|
||||
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
|
||||
<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"
|
||||
@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="user?.image" :src="user.image" class="w-full h-full object-cover" />
|
||||
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||
<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">{{
|
||||
user!.name
|
||||
}}</span>
|
||||
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-subtle)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
|
||||
<div :class="['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',
|
||||
hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-x-0 scale-y-90'
|
||||
]">
|
||||
<Icon class="text-4" name="mynaui:chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</header>
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown'
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
const { user } = useAuth()
|
||||
const { agents, activeAgent } = await useAgents()
|
||||
const homeButtonRef = ref<HTMLElement | null>(null)
|
||||
const route = useRoute();
|
||||
const { agents, getAgent } = await useAgents();
|
||||
const homeButtonRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const hovering = defineModel<boolean>({ required: true })
|
||||
const initialized = ref(false)
|
||||
const activeAgent = computed(() => getAgent(route.params.id as string));
|
||||
|
||||
const hovering = defineModel<boolean>({ required: true });
|
||||
const initialized = ref(false);
|
||||
|
||||
let lastHovering: boolean | null = null;
|
||||
onMounted(() => {
|
||||
if (hovering.value) {
|
||||
const width = homeButtonRef.value!.scrollWidth
|
||||
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
|
||||
console.log(hovering.value);
|
||||
|
||||
if (hovering.value && homeButtonRef.value) {
|
||||
const width = homeButtonRef.value.scrollWidth;
|
||||
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
|
||||
}
|
||||
|
||||
watch(hovering, (value) => {
|
||||
if (!initialized.value) {
|
||||
initialized.value = true
|
||||
if (lastHovering === value) {
|
||||
console.warn('Hovering value did not change, but watcher was triggered');
|
||||
}
|
||||
console.log(value, lastHovering);
|
||||
lastHovering = value;
|
||||
|
||||
if (!initialized.value) {
|
||||
initialized.value = true;
|
||||
}
|
||||
|
||||
if (!homeButtonRef.value) return;
|
||||
|
||||
if (value) {
|
||||
const width = homeButtonRef.value!.scrollWidth
|
||||
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
|
||||
const width = homeButtonRef.value.scrollWidth;
|
||||
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
|
||||
} else {
|
||||
homeButtonRef.value!.style.width = '0'
|
||||
homeButtonRef.value.style.width = '0';
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const agentDropdownOpen = ref(false)
|
||||
onUnmounted(() => {
|
||||
console.log('unmounted');
|
||||
});
|
||||
|
||||
const agentItems: DropdownItem[] = []
|
||||
const agentDropdownOpen = ref(false);
|
||||
|
||||
const agentItems: DropdownItem[] = [];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,14 +55,16 @@ const agentItems: DropdownItem[] = []
|
||||
<div ref="homeButtonRef" style="width: 0;"
|
||||
:class="['flex flex-shrink-0 items-center overflow-hidden', initialized ? 'transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]' : '', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
|
||||
<NuxtLink to="/"
|
||||
class="flex hover:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-subtle)] p-1.5">
|
||||
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">
|
||||
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<Dropdown v-model="agentDropdownOpen" :items="agentItems" placement="center" width="calc(80% - 1rem)">
|
||||
<Dropdown class="overflow-hidden" v-model="agentDropdownOpen" :items="agentItems" placement="center"
|
||||
width="calc(80% - 1rem)">
|
||||
<template #trigger="{ toggle }">
|
||||
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
|
||||
<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"
|
||||
@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)]']">
|
||||
@@ -57,20 +76,18 @@ const agentItems: DropdownItem[] = []
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
|
||||
{{ activeAgent?.name }}
|
||||
</span>
|
||||
<div class="w-4 h-4 text-[var(--color-subtle)]">
|
||||
<div class="w-4 h-4 text-[var(--color-muted)]">
|
||||
<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>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
|
||||
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id" :class="['decoration-none whitespace-nowrap', activeAgent?.id === agent.id ? 'text-[var(--color-text)]' :
|
||||
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
|
||||
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
|
||||
:active="activeAgent?.id === agent.id" />
|
||||
</NuxtLink>
|
||||
<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>
|
||||
|
||||
@@ -1,17 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
name: string,
|
||||
icon: string,
|
||||
active?: boolean
|
||||
}>()
|
||||
name: string;
|
||||
icon?: string;
|
||||
to?: string;
|
||||
active?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="button"
|
||||
:class="['flex items-center gap-2 px-1 rounded-lg hover:bg-[var(--color-highlight)] transition-colors cursor-pointer h-9 overflow-hidden', props.active ? 'bg-[var(--color-highlight)]' : '']">
|
||||
<div class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon class="text-4.5" :name="props.icon" />
|
||||
<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',
|
||||
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)]'
|
||||
]">
|
||||
<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">
|
||||
<Icon class="text-4.5" :name="props.icon" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
}}</span>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis">{{ props.name }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<button v-else v-bind="$attrs" :aria-label="props.name" :class="[
|
||||
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
|
||||
props.icon ? 'px-1' : 'px-2',
|
||||
props.active
|
||||
? 'text-[var(--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="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon class="text-4.5" :name="props.icon" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
}}</span>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,5 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const topicsListRef = ref<HTMLElement | null>(null);
|
||||
const topicsListHeight = ref('auto');
|
||||
const topicsListOpacity = ref(1);
|
||||
const topicsListScale = ref(1);
|
||||
const { getAgent } = await useAgents();
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
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('/');
|
||||
@@ -18,19 +36,122 @@ const pageInfo = computed(() => {
|
||||
return 'agent-profile';
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const renameTopic = (topicId: string) => {
|
||||
console.log('renameTopic', topicId);
|
||||
};
|
||||
|
||||
const deleteTopic = async (topicId: string) => {
|
||||
if (route.params.topicId === topicId) {
|
||||
if (route.params.id) {
|
||||
await navigateTo(`/agent/${route.params.id}`);
|
||||
} else {
|
||||
await navigateTo('/');
|
||||
}
|
||||
}
|
||||
await triplit.delete('topics', topicId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-1">
|
||||
|
||||
<!-- Agent Info Link -->
|
||||
<div class="mt-2">
|
||||
<NuxtLink :to="`/agent/${routeParts[0]}/profile`" class="decoration-none text-[var(--color-subtle)]">
|
||||
<SidenavItem name="Agent Info" icon="mynaui:info-square" :active="pageInfo === 'agent-profile'" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col">
|
||||
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square"
|
||||
:active="pageInfo === 'agent-profile'" />
|
||||
|
||||
<!-- Topics Section -->
|
||||
<SidenavNavAgentTopics />
|
||||
<!-- 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">
|
||||
<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"
|
||||
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
|
||||
<SidenavItem draggable="false" class="[&>div>div>div>[dots]]:hover:opacity-100 relative"
|
||||
v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
|
||||
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :active="topic.id === route.params.topicId"
|
||||
:name="topic.name" :key="topic.id">
|
||||
<Dropdown class="shrink-0" verticality="descending" placement="right">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<div dots @click.prevent.stop="toggle"
|
||||
class="opacity-0 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>
|
||||
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
|
||||
<button @click.prevent="renameTopic(topic.id)"
|
||||
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)"
|
||||
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>
|
||||
</SidenavItem>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const appState = useAppState();
|
||||
const topicsListRef = ref<HTMLElement | null>(null);
|
||||
const topicsListHeight = ref('auto');
|
||||
const topicsListOpacity = ref(1);
|
||||
const topicsListScale = ref(1);
|
||||
const { topicsForActiveAgent, createTopic } = await useTopics();
|
||||
const { activeAgent } = await useAgents();
|
||||
|
||||
const creatingTopic = ref(false);
|
||||
const topicsOpen = ref(true);
|
||||
|
||||
function easeInOutQuad(x: number): number {
|
||||
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
|
||||
}
|
||||
|
||||
const toggleAgentsList = () => {
|
||||
if (!topicsListRef.value) return;
|
||||
let animationLength = 200;
|
||||
let animationStart: number | null = null;
|
||||
|
||||
let startHeight: number;
|
||||
let startOpacity = topicsListOpacity.value;
|
||||
let startScale = topicsListScale.value;
|
||||
if (topicsListHeight.value === 'auto') {
|
||||
startHeight = topicsListRef.value.clientHeight;
|
||||
} else {
|
||||
startHeight = Number(topicsListHeight.value.replace('px', ''));
|
||||
}
|
||||
|
||||
let targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
|
||||
let targetOpacity = topicsOpen.value ? 0 : 1;
|
||||
let targetScale = topicsOpen.value ? 0.95 : 1;
|
||||
topicsOpen.value = !topicsOpen.value;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!animationStart) animationStart = timestamp;
|
||||
|
||||
const elapsed = timestamp - animationStart;
|
||||
const progress = Math.min(elapsed / animationLength, 1);
|
||||
|
||||
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
|
||||
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
|
||||
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
|
||||
|
||||
topicsListOpacity.value = currentOpacity;
|
||||
topicsListScale.value = currentScale;
|
||||
topicsListHeight.value = `${currentHeight}px`;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (topicsOpen.value) {
|
||||
topicsListHeight.value = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
const newTopic = async () => {
|
||||
if (!activeAgent.value) return;
|
||||
|
||||
creatingTopic.value = true;
|
||||
try {
|
||||
const newTopic = await createTopic('New Topic', activeAgent.value.id);
|
||||
// Navigate to new topic
|
||||
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to create topic:', error);
|
||||
} finally {
|
||||
creatingTopic.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Header -->
|
||||
<button @click="toggleAgentsList"
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] 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"
|
||||
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
|
||||
<button @click="newTopic" :disabled="creatingTopic"
|
||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover: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="creatingTopic" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||
</div>
|
||||
<span>{{ creatingTopic ? 'Creating...' : 'New Topic' }}</span>
|
||||
</button>
|
||||
|
||||
<NuxtLink v-for="topic in topicsForActiveAgent" :to="`/agent/${activeAgent?.id}/topic/${topic.id}`"
|
||||
class="decoration-none text-[var(--color-subtle)]">
|
||||
<SidenavItem :name="topic.name" icon="mynaui:check-hexagon" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const { agents, createAgent } = await useAgents()
|
||||
const agentsListRef = ref<HTMLElement | null>(null)
|
||||
const agentsOpen = ref(true)
|
||||
const agentsListHeight = ref('auto')
|
||||
const agentsListOpacity = ref(1)
|
||||
const agentsListScale = ref(1)
|
||||
const creatingAgent = ref(false)
|
||||
const { agents } = await useAgents();
|
||||
const agentsListRef = ref<HTMLElement | null>(null);
|
||||
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 - Math.pow(-2 * x + 2, 2) / 2;
|
||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
const toggleAgentsList = () => {
|
||||
if (!agentsListRef.value) return;
|
||||
let animationLength = 200;
|
||||
const animationLength = 200;
|
||||
let animationStart: number | null = null;
|
||||
|
||||
let startHeight: number;
|
||||
let startOpacity = agentsListOpacity.value;
|
||||
let startScale = agentsListScale.value;
|
||||
const startOpacity = agentsListOpacity.value;
|
||||
const startScale = agentsListScale.value;
|
||||
if (agentsListHeight.value === 'auto') {
|
||||
startHeight = agentsListRef.value.clientHeight;
|
||||
} else {
|
||||
startHeight = Number(agentsListHeight.value.replace('px', ''));
|
||||
}
|
||||
|
||||
let targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
|
||||
let targetOpacity = agentsOpen.value ? 0 : 1;
|
||||
let targetScale = agentsOpen.value ? 0.95 : 1;
|
||||
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) => {
|
||||
@@ -54,36 +54,59 @@ const toggleAgentsList = () => {
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
const newAgent = async () => {
|
||||
creatingAgent.value = true;
|
||||
const agent = await createAgent();
|
||||
creatingAgent.value = false;
|
||||
navigateTo(`/agent/${agent!.id}`);
|
||||
}
|
||||
const triplit = useTriplitClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user.value) throw new Error('User not logged in');
|
||||
|
||||
const agent = await triplit.insert('agents', {
|
||||
name: 'New Agent',
|
||||
userId: user.value.id,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
imageUrl: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(agents.value, agent);
|
||||
if (!agent) throw new Error('Failed to create agent');
|
||||
|
||||
let agentExists: () => void;
|
||||
const agentExistsPromise = new Promise<void>((resolve) => {
|
||||
agentExists = resolve;
|
||||
});
|
||||
|
||||
watch(agents, () => {
|
||||
agentExists();
|
||||
});
|
||||
|
||||
await agentExistsPromise;
|
||||
|
||||
navigateTo(`/agent/${agent.id}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-1">
|
||||
<SidenavItem name="Search" icon="mynaui:search" />
|
||||
<NuxtLink to="/" class="decoration-none text-[var(--color-text)]">
|
||||
<SidenavItem class="bg-[var(--color-highlight)]" name="Home" icon="mynaui:home" />
|
||||
</NuxtLink>
|
||||
<SidenavItem to="/" :active="true" name="Home" icon="mynaui:home" />
|
||||
|
||||
<!-- Agents Section -->
|
||||
<div class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left"
|
||||
<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()">
|
||||
<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']" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref="agentsListRef"
|
||||
<div ref="agentsListRef" :inert="!agentsOpen"
|
||||
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
|
||||
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-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--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" />
|
||||
@@ -91,10 +114,9 @@ const newAgent = async () => {
|
||||
<span>New Agent</span>
|
||||
</button>
|
||||
|
||||
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
|
||||
class="decoration-none text-[var(--color-subtle)]">
|
||||
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
|
||||
</NuxtLink>
|
||||
<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>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,93 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar()
|
||||
const route = useRoute()
|
||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
||||
const route = useRoute();
|
||||
|
||||
const isResizing = ref(false)
|
||||
const startX = ref(0)
|
||||
const initialWidth = ref(0)
|
||||
const isResizing = ref(false);
|
||||
const startX = ref(0);
|
||||
const initialWidth = ref(0);
|
||||
|
||||
const closeSidenavRef = ref<HTMLElement | null>(null)
|
||||
const sidenavRef = ref<HTMLElement | null>(null);
|
||||
const closeSidenavRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const { toggle: toggleSettings } = useSettings();
|
||||
|
||||
const onResizeStart = (event: MouseEvent) => {
|
||||
isResizing.value = true
|
||||
startX.value = event.clientX
|
||||
initialWidth.value = sidebarWidth.value
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
isResizing.value = true;
|
||||
startX.value = event.clientX;
|
||||
initialWidth.value = sidebarWidth.value;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const onResizeMove = (event: MouseEvent) => {
|
||||
if (!isResizing.value) return
|
||||
if (!isResizing.value) return;
|
||||
if (resizeAnimationFrame) return;
|
||||
|
||||
resizeAnimationFrame = requestAnimationFrame(() => {
|
||||
const deltaX = event.clientX - startX.value
|
||||
const newWidth = initialWidth.value + deltaX
|
||||
resize(newWidth)
|
||||
resizeAnimationFrame = null
|
||||
})
|
||||
}
|
||||
const deltaX = event.clientX - startX.value;
|
||||
const newWidth = initialWidth.value + deltaX;
|
||||
resize(newWidth);
|
||||
resizeAnimationFrame = null;
|
||||
});
|
||||
};
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing.value) return
|
||||
if (!isResizing.value) return;
|
||||
|
||||
isResizing.value = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
saveWidth()
|
||||
}
|
||||
isResizing.value = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
saveWidth();
|
||||
};
|
||||
|
||||
let resizeAnimationFrame: number | null = null
|
||||
let resizeAnimationFrame: number | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onResizeMove)
|
||||
document.addEventListener('mouseup', onResizeEnd)
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('mouseup', onResizeEnd);
|
||||
|
||||
watch(hovering, (value) => {
|
||||
if (!closeSidenavRef.value) return;
|
||||
|
||||
if (value) {
|
||||
const width = closeSidenavRef.value!.scrollWidth
|
||||
closeSidenavRef.value!.style.width = `${width}px`
|
||||
const width = closeSidenavRef.value.scrollWidth;
|
||||
closeSidenavRef.value.style.width = `${width}px`;
|
||||
} else {
|
||||
closeSidenavRef.value!.style.width = '0'
|
||||
closeSidenavRef.value.style.width = '0';
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
})
|
||||
document.removeEventListener('mousemove', onResizeMove);
|
||||
document.removeEventListener('mouseup', onResizeEnd);
|
||||
});
|
||||
|
||||
const hovering = ref(false)
|
||||
const hovering = ref(false);
|
||||
|
||||
const navKind = computed(() => {
|
||||
if (route.path === '/') return 'home'
|
||||
if (route.path.startsWith('/agent/')) return 'agent'
|
||||
return null
|
||||
})
|
||||
if (route.path === '/') return 'home';
|
||||
if (route.path.startsWith('/agent/')) return 'agent';
|
||||
return null;
|
||||
});
|
||||
|
||||
const onFocusOut = (e: FocusEvent) => {
|
||||
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
|
||||
if (isMovingOutside) {
|
||||
hovering.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<aside :class="[
|
||||
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-subtle)] select-none',
|
||||
<aside ref="sidenavRef" :class="[
|
||||
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-muted)] select-none',
|
||||
open ? 'w-full mr-2' : 'w-0 mr-0',
|
||||
isResizing ? '' : 'transition-[width,margin] duration-250 ease-[cubic-bezier(0,0.55,0.45,1)]'
|
||||
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="hovering = true"
|
||||
@mouseleave="hovering = false">
|
||||
@mouseleave="hovering = false" @focusin="hovering = true" @focusout="onFocusOut">
|
||||
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col h-full max-h-full overflow-y-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
|
||||
|
||||
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
|
||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" v-model="hovering" />
|
||||
|
||||
<div class="flex items-center justify-end text-[var(--color-subtle)] gap-0.5">
|
||||
<div class="flex items-center justify-end text-[var(--color-muted)] gap-0.5">
|
||||
<div ref="closeSidenavRef" style="width: 0;"
|
||||
: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="[
|
||||
'text-5 p-1.5 hover:bg-[var(--color-highlight)] bg-transparent transition-inherit',
|
||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] scale-100 bg-transparent transition-inherit',
|
||||
]">
|
||||
<Icon name="mynaui:panel-left-close"
|
||||
:class="['transition-inherit', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
|
||||
@@ -95,7 +107,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 p-1.5 hover:bg-[var(--color-highlight)] bg-transparent text-inherit',
|
||||
'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',
|
||||
]">
|
||||
<Icon name="mynaui:book-plus" />
|
||||
</NuxtLink>
|
||||
@@ -104,15 +116,24 @@ const navKind = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Main Menu -->
|
||||
<div class="max-h-full overflow-auto">
|
||||
<div class="max-h-full h-full overflow-auto" style="scrollbar-width: thin;">
|
||||
<SidenavNavHome v-if="navKind === 'home'" />
|
||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher -->
|
||||
<div class="flex justify-end">
|
||||
<ThemeSwitcher />
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher -->
|
||||
<div class="flex justify-end gap-1">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: String
|
||||
},
|
||||
label: {
|
||||
type: String
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const active = ref(props.checked);
|
||||
|
||||
watch(() => props.checked, (newValue) => {
|
||||
active.value = newValue;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button role="switch" class="vl-toggle-switch" :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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vl-toggle-switch {
|
||||
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 {
|
||||
position: relative;
|
||||
left: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: #f7f7f7;
|
||||
border-radius: 90px;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] div {
|
||||
left: 100%;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.vl-toggle-switch:active div {
|
||||
width: 1.3em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown'
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const colorMode = useColorMode();
|
||||
|
||||
const themeOptions: DropdownItem[] = [
|
||||
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
||||
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
|
||||
]
|
||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' },
|
||||
];
|
||||
|
||||
const currentOption = computed(() =>
|
||||
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
|
||||
)
|
||||
const currentOption = computed(
|
||||
() => themeOptions.find((option) => option.value === colorMode.preference) || themeOptions[2],
|
||||
);
|
||||
|
||||
const isOpen = ref(false)
|
||||
const isOpen = ref(false);
|
||||
|
||||
const selectTheme = (item: DropdownItem) => {
|
||||
colorMode.preference = item.value as Theme
|
||||
}
|
||||
colorMode.preference = item.value as Theme;
|
||||
};
|
||||
</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="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
|
||||
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
|
||||
<Icon :name="currentOption!.icon!"
|
||||
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user