fix: UI polish and provider fixes
- Add animate-pulse CSS animation for loading states - Add Ctrl+I/Ctrl+B markdown shortcuts in chat input - Improve chat input resize with mirror element - Add reasoning duration display and shimmer effect - Add confirmation dialog for deleting all models - Simplify RowVirtualizerDynamic resize handling - Fix DialogType imports (type -> value) - Fix ollama cloud model name resolution - Make vllm auth header optional - Various icon and styling fixes
This commit is contained in:
@@ -224,6 +224,45 @@ button.accent:hover {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
background: linear-gradient(90deg,
|
||||
currentColor 0%,
|
||||
currentColor 35%,
|
||||
rgba(255, 255, 255, 0.65) 50%,
|
||||
currentColor 65%,
|
||||
currentColor 100%);
|
||||
background-size: 300% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation:
|
||||
pulse-shimmer 2s linear infinite,
|
||||
pulse-opacity 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
0%,
|
||||
|
||||
@@ -32,6 +32,7 @@ watch(files, (newFiles) => {
|
||||
const emit = defineEmits<{
|
||||
submit: [value: BaseMessage, model: ModelWithProvider | null];
|
||||
cancel: [];
|
||||
resize: [];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -147,11 +148,50 @@ const handleSubmit = () => {
|
||||
files.value = [];
|
||||
textAreaValue.value = '';
|
||||
}
|
||||
// Reset height after sending
|
||||
if (inputRef.value) inputRef.value.style.height = 'auto';
|
||||
};
|
||||
|
||||
const toggleMarkdown = (marker: '*' | '**') => {
|
||||
const textarea = inputRef.value;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
if (start === end) return;
|
||||
|
||||
const text = textAreaValue.value;
|
||||
const len = marker.length;
|
||||
|
||||
let countBefore = 0;
|
||||
for (let i = start - 1; i >= 0 && text[i] === '*'; i--) countBefore++;
|
||||
let countAfter = 0;
|
||||
for (let i = end; i < text.length && text[i] === '*'; i++) countAfter++;
|
||||
|
||||
const shouldRemove = len === 1
|
||||
? countBefore >= 1 && countAfter >= 1 && countBefore % 2 === 1 && countAfter % 2 === 1
|
||||
: countBefore >= 2 && countAfter >= 2;
|
||||
|
||||
if (shouldRemove) {
|
||||
textAreaValue.value = text.slice(0, start - len) + text.slice(start, end) + text.slice(end + len);
|
||||
nextTick(() => textarea.setSelectionRange(start - len, end - len));
|
||||
} else {
|
||||
textAreaValue.value = text.slice(0, start) + marker + text.slice(start, end) + marker + text.slice(end);
|
||||
nextTick(() => textarea.setSelectionRange(start + len, end + len));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'i') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleMarkdown('*');
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleMarkdown('**');
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) return;
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
@@ -195,16 +235,43 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const mirrorRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const resizeTextArea = () => {
|
||||
const textarea = inputRef.value;
|
||||
if (!textarea) return;
|
||||
const mirror = mirrorRef.value;
|
||||
if (!textarea || !mirror) return;
|
||||
|
||||
inputHeight.value = 'auto';
|
||||
emit('resize');
|
||||
nextTick().then(() => {
|
||||
mirror.textContent = (textarea.value || '\u200b') + '\n';
|
||||
|
||||
const textareaStyles = getComputedStyle(textarea);
|
||||
mirror.style.width = textareaStyles.width;
|
||||
mirror.style.fontFamily = textareaStyles.fontFamily;
|
||||
mirror.style.fontSize = textareaStyles.fontSize;
|
||||
mirror.style.lineHeight = textareaStyles.lineHeight;
|
||||
mirror.style.letterSpacing = textareaStyles.letterSpacing;
|
||||
mirror.style.wordSpacing = textareaStyles.wordSpacing;
|
||||
mirror.style.textAlign = textareaStyles.textAlign;
|
||||
mirror.style.paddingTop = textareaStyles.paddingTop;
|
||||
mirror.style.paddingBottom = textareaStyles.paddingBottom;
|
||||
mirror.style.paddingLeft = textareaStyles.paddingLeft;
|
||||
mirror.style.paddingRight = textareaStyles.paddingRight;
|
||||
mirror.style.borderTopWidth = textareaStyles.borderTopWidth;
|
||||
mirror.style.borderBottomWidth = textareaStyles.borderBottomWidth;
|
||||
mirror.style.borderLeftWidth = textareaStyles.borderLeftWidth;
|
||||
mirror.style.borderRightWidth = textareaStyles.borderRightWidth;
|
||||
mirror.style.whiteSpace = 'pre-wrap';
|
||||
mirror.style.wordWrap = 'break-word';
|
||||
mirror.style.overflow = 'hidden';
|
||||
|
||||
const lineHeight = 24;
|
||||
const minLines = 2;
|
||||
const maxLines = 10;
|
||||
const minHeight = minLines * lineHeight;
|
||||
const maxHeight = maxLines * lineHeight;
|
||||
const height = Math.min(textarea.scrollHeight, maxHeight);
|
||||
const height = Math.min(Math.max(mirror.scrollHeight, minHeight), maxHeight);
|
||||
inputHeight.value = `${height}px`;
|
||||
});
|
||||
};
|
||||
@@ -249,6 +316,8 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div :class="['w-full flex max-h-full', $attrs.class]">
|
||||
<div ref="mirrorRef" aria-hidden="true"
|
||||
class="absolute top-0 left-0 pointer-events-none invisible overflow-hidden h-0"></div>
|
||||
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-2 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
|
||||
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
|
||||
<div v-if="files.length > 0" class="flex-1 flex gap-2 pt-2 px-2 pb-1 overflow-x-auto flex-wrap">
|
||||
|
||||
@@ -53,6 +53,20 @@ const toggleReasoning = async () => {
|
||||
scrollToBottom('instant');
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = ms / 1000;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${(seconds % 60).toFixed(1)}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${(seconds % 60).toFixed(1)}s`;
|
||||
} else {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -63,10 +77,16 @@ const toggleReasoning = async () => {
|
||||
]">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
|
||||
<span class="i-mynaui-atom text-2.5 text-[var(--reasoning-accent)]"></span>
|
||||
class="w-6 h-6 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
|
||||
<span class="i-mynaui-atom text-3 text-[var(--reasoning-accent)]"></span>
|
||||
</span>
|
||||
<span v-if="!part.finished" class="animate-pulse">
|
||||
Deep Thinking
|
||||
</span>
|
||||
<span v-else>
|
||||
Deeply Thought (in {{ formatDuration(new Date(part.lastUpdatedAt).getTime() - new
|
||||
Date(part.createdAt).getTime()) }})
|
||||
</span>
|
||||
Deep Thinking
|
||||
</span>
|
||||
<span class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)]"
|
||||
:class="reasoningOpen ? '' : '-rotate-90'"></span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
|
||||
import type { DialogType } from '~/composables/useDialog';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
||||
|
||||
@@ -33,29 +33,15 @@ const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
|
||||
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
|
||||
|
||||
let resizeRafId: number | undefined = undefined;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeRafId) return;
|
||||
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = undefined;
|
||||
rowVirtualizer.value.measure();
|
||||
updateScrollMargin();
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver.observe(containerRef.value!);
|
||||
window.addEventListener('resize', updateScrollMargin);
|
||||
updateScrollMargin();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', updateScrollMargin);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Keep the measure function simple
|
||||
const measureElement = (el: any) => {
|
||||
if (el) {
|
||||
rowVirtualizer.value.measureElement(el);
|
||||
@@ -75,7 +61,6 @@ watch(() => props.items, () => {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
minHeight: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||
}">
|
||||
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
|
||||
|
||||
@@ -106,7 +106,7 @@ const openSettings = () => {
|
||||
</span>
|
||||
<button @click="adjustMaxResults(1)"
|
||||
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
|
||||
<span class="text-sm i-mynaui-plus"></span>
|
||||
<span class="text-sm i-mynaui-plus-solid"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -422,11 +422,33 @@ defineEmits(['navigate']);
|
||||
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
|
||||
Model List
|
||||
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
|
||||
{{ provider?.models.length }} models available <button
|
||||
class="p-0.5 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
|
||||
@click="deleteModels">
|
||||
<span class="i-mynaui-x-solid"></span>
|
||||
</button>
|
||||
{{ provider?.models.length }} models available
|
||||
<Dropdown placement="bottom">
|
||||
<template #default="{ toggle, setRef }">
|
||||
<button :ref="setRef" @click="toggle"
|
||||
class="p-0.5 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200">
|
||||
<span class="i-mynaui-x-solid"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template #dropdown="{ close }">
|
||||
<div class="px-3 py-2.5 w-64 flex flex-col gap-3">
|
||||
<p class="text-sm text-[var(--text-primary)] m-0 leading-snug text-center">
|
||||
Are you sure you want to delete all models? This will remove
|
||||
<strong>all</strong> fetched models for this provider
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="close()"
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-md transition-colors duration-200 border border-[var(--color-border)] @hover:border-transparent">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="deleteModels(); close()"
|
||||
class="px-3 py-1.5 text-sm bg-red-500 text-white rounded-md @hover:bg-red-600 transition-colors duration-200">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
@@ -449,7 +471,7 @@ defineEmits(['navigate']);
|
||||
<div class="flex">
|
||||
<button @click="openAddPanel"
|
||||
class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] text-sm rounded-l-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-plus text-5"></span>
|
||||
<span class="i-mynaui-plus-solid text-5"></span>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogType } from '~/composables/useDialog';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { navigateTo } from '#app';
|
||||
const route = useRoute();
|
||||
const { agents, getAgent } = await useAgents();
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ onMounted(() => {
|
||||
|
||||
<div data-action="toggle-dropdown"
|
||||
class="text-[var(--text-secondary)] shrink-0 w-0 max-w-0 opacity-0 overflow-hidden p-1 max-md:w-auto max-md:max-w-none max-md:opacity-100 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]"
|
||||
:class="route.params.topicId === topic.id ? 'w-auto max-w-none opacity-100' : 'group-hover:w-auto group-hover:max-w-none group-hover:opacity-100'">
|
||||
:class="(route.params.topicId === topic.id || (dropdownOpen && activeMenuTopicId === topic.id)) ? 'w-auto max-w-none opacity-100' : 'group-hover:w-auto group-hover:max-w-none group-hover:opacity-100'">
|
||||
<span class="pointer-events-none h-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -30,7 +30,7 @@ const currentOption = computed(
|
||||
<button :ref="setRef" @click="toggle"
|
||||
class="flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors active:text-[var(--text-primary)]"
|
||||
:class="{
|
||||
'h-9 w-9 md:h-7 md:w-7 text-5 md:text-6': size === 'small',
|
||||
'h-9 w-9 md:h-7 md:w-7 md:text-5 text-6': size === 'small',
|
||||
'h-9 w-9 text-6': size === 'medium',
|
||||
'h-11 w-11 text-7': size === 'large',
|
||||
}">
|
||||
@@ -46,7 +46,7 @@ const currentOption = computed(
|
||||
}">
|
||||
<span class="text-4.5" :class="option.icon"></span>
|
||||
<span class="text-sm whitespace-nowrap text-ellipsis max-w-full overflow-hidden">{{ option.label
|
||||
}}</span>
|
||||
}}</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-expect-error - shut up
|
||||
await db.insert(agents).values(result.data).onConflictDoNothing();
|
||||
userEvents.emit(userId, 'agents', {
|
||||
op: 'create',
|
||||
|
||||
@@ -103,12 +103,13 @@ export default {
|
||||
normalizedId = normalizedId.replace(/:cloud$/, '');
|
||||
normalizedId = normalizedId.replace(/-cloud$/, '');
|
||||
normalizedId = normalizedId.replace(/:latest$/, '');
|
||||
console.log(normalizedId);
|
||||
const modelData = getModelData(normalizedId, 'ollama-cloud', modelsDevData);
|
||||
|
||||
models.set(model.name, {
|
||||
...modelData,
|
||||
name: modelData.name || model.name,
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
attributes: {
|
||||
...modelData.attributes,
|
||||
inputModalities: mergeSets(
|
||||
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
async fetchModels({ baseURL, apiKey }) {
|
||||
const res = await fetch(`${baseURL}/models`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user