feat: file upload retry, secure file tokens, UI polish

- Add HMAC-based file token auth for secure AI model file access
- Add file upload retry with exponential backoff (max 3 retries)
- File endpoint now requires session auth or signed token
- Support assistant role messages in chat input
- Optimistic UI for attachments on message send
- Verify topic ownership before allowing messages
- Switch web scraping to Firecrawl API
- Agent profile page layout fixes (proper flex overflow)
- Add quick switcher (Ctrl+K) to sidenav
- Clean up longcat.ts and stale comments
This commit is contained in:
Zoe
2026-06-06 00:16:39 -05:00
parent 47009b1f0a
commit 8ccaa824dd
20 changed files with 827 additions and 406 deletions
+18 -1
View File
@@ -10,9 +10,14 @@ const props = defineProps<{
status?: 'uploading' | 'uploaded' | 'error';
url: string;
progress?: number;
retryCount?: number;
};
}>();
const emit = defineEmits<{
retry: [];
}>();
const isImage = computed(() => props.file.mimeType.startsWith('image/'));
const isVideo = computed(() => props.file.mimeType.startsWith('video/'));
const isAudio = computed(() => props.file.mimeType.startsWith('audio/'));
@@ -71,8 +76,20 @@ function openImageViewer() {
class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center">
<div class="text-center">
<span class="i-svg-spinners-90-ring-with-bg text-6 text-white block mb-1"></span>
<span class="text-xs text-white">{{ file.progress || 0 }}%</span>
<span class="text-xs text-white">
{{ file.retryCount ? `Retry ${file.retryCount}/3` : `${file.progress || 0}%` }}
</span>
</div>
</div>
<div v-else-if="file.status === 'error'"
class="absolute inset-0 bg-black/60 rounded-lg flex flex-col items-center justify-center gap-1">
<span class="i-mynaui-danger-triangle text-6 text-red-400"></span>
<span class="text-xs text-red-300">Upload failed</span>
<button @click.stop="emit('retry')"
class="mt-1 flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/10 hover:bg-white/20 text-xs text-white transition-colors">
<span class="i-mynaui-refresh text-3"></span>
Retry
</button>
</div>
</div>
</template>
+8 -2
View File
@@ -8,11 +8,13 @@ const props = defineProps<{
mimeType: string;
status: 'uploading' | 'uploaded' | 'error';
url: string;
retryCount?: number;
}
}>();
const emit = defineEmits<{
delete: [];
retry: [];
}>();
const handleDelete = async () => {
@@ -21,12 +23,16 @@ const handleDelete = async () => {
</script>
<template>
<div class="relative h-full w-fit flex">
<Display :file="props.file" />
<div class="relative h-full w-fit flex flex-shrink-0">
<Display :file="props.file" @retry="emit('retry')" />
<button @click="handleDelete"
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 flex items-center justify-center w-4 h-4 rounded-full bg-[var(--bg-base)] border border-[var(--color-border)] text-xs text-[#ff3b3b]">
<span class="i-mynaui-x text-3"></span>
</button>
<div v-if="file.status === 'error'"
class="absolute bottom-0 right-0 translate-x-1/4 translate-y-1/4 flex items-center justify-center w-4 h-4 rounded-full bg-red-500">
<span class="i-mynaui-danger-triangle text-2.5 text-white"></span>
</div>
</div>
</template>
+94 -8
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { BaseMessage } from '~/composables/useChat';
import { onMounted, ref, watch, onUnmounted, nextTick, type Ref } from 'vue';
import { computed, onMounted, ref, watch, onUnmounted, nextTick, type Ref } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Agent } from '~/composables/useAgents';
import type FileSelector from './FileSelector.vue';
@@ -19,6 +19,7 @@ const files = ref<{
status: 'uploading' | 'uploaded' | 'error';
url: string;
progress: number;
retryCount: number;
}[]>([]);
const inputValue = defineModel<BaseMessage>({ required: false, default: { content: '', fileIds: [] } });
const textAreaValue = ref('');
@@ -31,12 +32,14 @@ watch(files, (newFiles) => {
const emit = defineEmits<{
submit: [value: BaseMessage, model: ModelWithProvider | null];
addMessage: [value: BaseMessage, role: 'user' | 'assistant', model: ModelWithProvider | null];
cancel: [];
resize: [];
}>();
const props = defineProps<{
loading?: boolean;
allowManualRole?: boolean;
agent: Readonly<Agent> | null;
providers?: ProviderWithModels[];
}>();
@@ -70,6 +73,9 @@ const saveSearchConfig = async () => {
updateAgent(props.agent.id, { config: newConfig });
};
const isUploading = computed(() => files.value.some((f) => f.status === 'uploading'));
const hasFailedUploads = computed(() => files.value.some((f) => f.status === 'error'));
const selectedModel = ref<ModelWithProvider | null>(null);
const handlePaste = async (event: ClipboardEvent) => {
@@ -109,11 +115,18 @@ const initializeModel = () => {
const updateAgentDefaultModel = async (modelId: string) => {
if (!props.agent) return;
try {
const currentModelId = props.agent.defaultModelId;
await $fetch(`/api/agent/${props.agent.id}`, {
method: 'PATCH',
body: {
defaultModelId: modelId,
},
onRequest: () => {
patchAgentLocally(props.agent!.id, { defaultModelId: modelId });
},
onResponseError: () => {
patchAgentLocally(props.agent!.id, { defaultModelId: currentModelId });
},
});
} catch (error) {
console.error('Failed to update agent default model:', error);
@@ -144,7 +157,35 @@ const handleSubmit = () => {
}
if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) {
emit('submit', structuredClone(toRaw(inputValue.value)), selectedModel.value);
const message: BaseMessage = {
content: inputValue.value.content,
fileIds: inputValue.value.fileIds,
files: files.value.map(f => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
url: f.url,
})),
};
emit('submit', message, selectedModel.value);
files.value = [];
textAreaValue.value = '';
}
};
const handleAddMessage = (role: 'user' | 'assistant') => {
if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) {
const message: BaseMessage = {
content: inputValue.value.content,
fileIds: inputValue.value.fileIds,
files: files.value.map(f => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
url: f.url,
})),
};
emit('addMessage', message, role, selectedModel.value);
files.value = [];
textAreaValue.value = '';
}
@@ -320,12 +361,13 @@ onUnmounted(() => {
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">
<div v-if="files.length > 0"
class="flex-shrink-0 flex flex-nowrap gap-2 pt-2 px-2 pb-1 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]">
<!-- TODO: show attachment previews -->
<AttachmentPreview v-for="file in files" @delete="files = files.filter((f) => f.id !== file.id)"
:key="file.id" :file="file" />
@retry="fileSelectorRef?.retryFile(file.id)" :key="file.id" :file="file" />
</div>
<div class="flex-1 min-w-0 max-h-full">
<div class="min-w-0 max-h-full">
<!-- Grammarly literally breaks everything, go fuck yourself -->
<!-- It is absolutely paramount that the closing tag for the textare has ZERO whitespace between the end of the textarea opening tag, otherwise there will be hydration errors -->
<textarea data-gramm="false" id="chat" v-model="textAreaValue" ref="inputRef"
@@ -346,10 +388,54 @@ onUnmounted(() => {
@update:rerank="(v: boolean) => { searchConfig.rerank = v; saveSearchConfig() }" />
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
</div>
<button aria-label="Send message" @click="handleSubmit"
:disabled="(!inputValue.content.trim() && files.length === 0) && !loading" :class="[
<div v-if="allowManualRole" class="flex items-center">
<button aria-label="Send message" @click="handleSubmit"
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads"
:class="[
'h-8 w-8 rounded-l-lg rounded-r-none transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)]'
: 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
]">
<span v-if="loading" class="text-6.5 i-mynaui-stop-solid"></span>
<span v-else class="text-5 i-mynaui-send-solid"></span>
</button>
<Dropdown placement="bottom-end">
<template #default="{ setRef, isOpen, toggle }">
<button :ref="setRef" aria-label="Message options" @click="toggle"
:disabled="(!inputValue.content.trim() && files.length === 0) || loading || isUploading || hasFailedUploads"
:class="[
'h-8 w-6 rounded-r-lg rounded-l-none transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed border-l border-[var(--text-dim)] disabled:border-transparent',
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] disabled:bg-transparent'
: 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
]">
<span class="text-4.5 i-mynaui-chevron-down" :class="{ 'rotate-180': isOpen }"></span>
</button>
</template>
<template #dropdown="{ close }">
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm text-[var(--text-primary)] @hover:bg-[var(--color-hover)] transition-colors whitespace-nowrap"
@click="handleAddMessage('user'); close()">
<span class="text-4 i-mynaui-user"></span>
Add as User
</button>
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm text-[var(--text-primary)] @hover:bg-[var(--color-hover)] transition-colors whitespace-nowrap"
@click="handleAddMessage('assistant'); close()">
<span class="text-4 i-mynaui-sparkles"></span>
Add as Assistant
</button>
</template>
</Dropdown>
</div>
<button v-else aria-label="Send message" @click="handleSubmit"
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads"
:class="[
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
(inputValue.content.trim() || files.length > 0) && !loading
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)]'
: 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
+115 -50
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { nanoid } from 'nanoid';
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 1000;
const props = defineProps<{
selectedModel?: ModelWithProvider | null;
}>();
@@ -15,10 +18,12 @@ const files = defineModel<{
status: 'uploading' | 'uploaded' | 'error';
url: string;
progress: number;
retryCount: number;
}[]>({ required: false, default: [] });
const rawFiles = ref<File[]>([]);
const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map());
const pendingFiles = ref<Map<string, File>>(new Map());
const uploadWithProgress = (file: File, url: string, id: string) => {
return new Promise((resolve, reject) => {
@@ -39,12 +44,16 @@ const uploadWithProgress = (file: File, url: string, id: string) => {
xhr.onload = () => {
activeUploads.value.delete(id);
resolve(xhr);
if (xhr.status >= 400) {
reject(new Error(`Upload failed with status ${xhr.status}`));
} else {
resolve(xhr);
}
};
xhr.onerror = () => {
activeUploads.value.delete(id);
reject(xhr);
reject(new Error('Network error during upload'));
};
xhr.onabort = () => {
@@ -57,68 +66,122 @@ const uploadWithProgress = (file: File, url: string, id: string) => {
});
};
const uploadFile = async (file: File) => {
try {
const id = nanoid();
const fileName = file.name || `${id}.png`;
const fileType = file.type || 'application/octet-stream';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
files.value.push({
const uploadFileAttempt = async (file: File, id: string, fileName: string, fileType: string) => {
const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', {
method: 'POST',
body: {
file: {
name: fileName,
mimeType: fileType,
}
},
}) as { url: string; assetUrl: string };
await uploadWithProgress(file, uploadUrl, id);
await $fetch('/api/file', {
method: 'POST',
body: {
id,
name: fileName,
mimeType: fileType,
status: 'uploading',
url: URL.createObjectURL(file),
progress: 0
});
size: file.size,
url: assetUrl,
},
});
};
const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', {
method: 'POST',
body: {
file: {
name: fileName,
mimeType: fileType,
}
},
}) as { url: string; assetUrl: string };
const uploadFile = async (file: File) => {
const id = nanoid();
const fileName = file.name || `${id}.png`;
const fileType = file.type || 'application/octet-stream';
await uploadWithProgress(file, uploadUrl, id);
files.value.push({
id,
name: fileName,
mimeType: fileType,
status: 'uploading',
url: URL.createObjectURL(file),
progress: 0,
retryCount: 0,
});
pendingFiles.value.set(id, file);
await $fetch('/api/file', {
method: 'POST',
body: {
id,
name: fileName,
mimeType: fileType,
size: file.size,
url: assetUrl,
},
})
await attemptUpload(file, id, fileName, fileType);
};
const attemptUpload = async (file: File, id: string, fileName: string, fileType: string, attempt = 0) => {
try {
await uploadFileAttempt(file, id, fileName, fileType);
files.value = files.value.map(f => {
if (f.name === file.name) {
return {
...f,
status: 'uploaded',
progress: 100
};
if (f.id === id) {
return { ...f, status: 'uploaded' as const, progress: 100 };
}
return f;
});
pendingFiles.value.delete(id);
} catch (error) {
files.value = files.value.map(f => {
if (f.name === file.name) {
return {
...f,
status: 'error'
};
}
return f;
});
}
}
if (error instanceof Error && error.message === 'Upload aborted') {
pendingFiles.value.delete(id);
return;
}
defineExpose({ uploadFile });
const nextAttempt = attempt + 1;
if (nextAttempt < MAX_RETRIES) {
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, retryCount: nextAttempt, progress: 0 };
}
return f;
});
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
await sleep(delay);
const stillExists = files.value.some(f => f.id === id);
if (!stillExists) return;
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, status: 'uploading' as const };
}
return f;
});
await attemptUpload(file, id, fileName, fileType, nextAttempt);
} else {
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, status: 'error' as const };
}
return f;
});
}
}
};
const retryFile = async (id: string) => {
const file = pendingFiles.value.get(id);
if (!file) return;
const entry = files.value.find(f => f.id === id);
if (!entry) return;
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, status: 'uploading' as const, progress: 0, retryCount: 0 };
}
return f;
});
await attemptUpload(file, id, entry.name, entry.mimeType);
};
defineExpose({ uploadFile, retryFile });
watch(rawFiles, async (newFiles, oldFiles) => {
const diff = newFiles.filter(f =>
@@ -146,6 +209,8 @@ watch(files, async (newFiles, oldFiles) => {
activeUploads.value.delete(removedFile.id);
}
pendingFiles.value.delete(removedFile.id);
if (removedFile.url.startsWith('blob:')) {
URL.revokeObjectURL(removedFile.url);
}
+13
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
import { DialogType } from '~/composables/useDialog';
const { openDialog } = useDialog();
const dropdownOpen = ref(false);
const dropdownTrigger = ref<HTMLElement | null>(null);
const dropdownContent = ref(null);
@@ -151,6 +153,17 @@ onMounted(() => {
<SidenavItem :to="`/agent/${agentId}/profile`" name="Agent Info" icon="i-mynaui-info-square"
:active="route.path.endsWith('/profile')" />
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search">
<div class="flex items-center gap-1">
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">ctrl</kbd>
</span>
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">k</kbd>
</span>
</div>
</SidenavItem>
<!-- Topics Section -->
<button @click="topicsOpen = !topicsOpen"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">