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:
@@ -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,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>
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
+88
-26
@@ -6,6 +6,13 @@ import { buildMessageTree } from '~~/utils/message';
|
||||
export type BaseMessage = {
|
||||
content: string;
|
||||
fileIds: string[];
|
||||
files?: {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
size?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type ToolCall = typeof schema.toolCalls.$inferSelect
|
||||
@@ -263,12 +270,6 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
|
||||
data.value = ssrData.value;
|
||||
} else {
|
||||
// if (data.value === undefined) {
|
||||
// const id = unref(topicId);
|
||||
// const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
|
||||
// data.value = ssrData.value;
|
||||
// }
|
||||
|
||||
await connectSSE();
|
||||
}
|
||||
}
|
||||
@@ -305,6 +306,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
const sendMessage = async (
|
||||
baseMessage: BaseMessage,
|
||||
onRequest?: () => void,
|
||||
role: 'user' | 'assistant' = 'user',
|
||||
): Promise<Result<void, ChatErrorType>> => {
|
||||
const { user } = useAuth();
|
||||
if (!user.value) return Err(ChatErrorType.NoUser);
|
||||
@@ -312,34 +314,94 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
try {
|
||||
const message = {
|
||||
id: nanoid(),
|
||||
role: 'user',
|
||||
content: baseMessage.content,
|
||||
fileIds: baseMessage.fileIds,
|
||||
role,
|
||||
}
|
||||
|
||||
await $fetch(`/api/topic/${unref(topicId)}/message`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
message
|
||||
message: {
|
||||
...message,
|
||||
content: baseMessage.content,
|
||||
fileIds: baseMessage.fileIds,
|
||||
}
|
||||
},
|
||||
onRequest() {
|
||||
if (data.value) {
|
||||
data.value!.messages.push({
|
||||
topicId: unref(topicId),
|
||||
userId: user.value!.id,
|
||||
// TODO
|
||||
attachments: [],
|
||||
parts: undefined,
|
||||
generation: null,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
activeChildId: null,
|
||||
deleted: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
children: [],
|
||||
...message
|
||||
} as Message);
|
||||
switch (role) {
|
||||
case 'user': {
|
||||
const optimisticAttachments = baseMessage.files?.map(file => ({
|
||||
id: nanoid(),
|
||||
userId: user.value!.id,
|
||||
topicId: unref(topicId),
|
||||
messageId: message.id,
|
||||
fileId: file.id,
|
||||
createdAt: new Date(),
|
||||
file: {
|
||||
id: file.id,
|
||||
userId: user.value!.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size ?? 0,
|
||||
url: file.url,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
})) ?? [];
|
||||
|
||||
data.value!.messages.push({
|
||||
topicId: unref(topicId),
|
||||
userId: user.value!.id,
|
||||
attachments: optimisticAttachments,
|
||||
parts: undefined,
|
||||
generation: null,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
activeChildId: null,
|
||||
children: [],
|
||||
deleted: false,
|
||||
content: baseMessage.content,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...message
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'assistant': {
|
||||
data.value!.messages.push({
|
||||
topicId: unref(topicId),
|
||||
userId: user.value!.id,
|
||||
// TODO
|
||||
attachments: [],
|
||||
generation: null,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
activeChildId: null,
|
||||
children: [],
|
||||
deleted: false,
|
||||
content: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parts: [
|
||||
{
|
||||
id: 'bogus-id',
|
||||
userId: user.value!.id,
|
||||
topicId: unref(topicId),
|
||||
messageId: message.id,
|
||||
type: 'text',
|
||||
content: baseMessage.content,
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
lastUpdatedAt: new Date(),
|
||||
providerOptions: null,
|
||||
toolCallId: null,
|
||||
toolCall: null,
|
||||
}
|
||||
],
|
||||
...message
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
onRequest?.();
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { BaseMessage } from '~/composables/useChat';
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
@@ -43,7 +44,23 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
|
||||
children: [],
|
||||
generationId: null,
|
||||
activeChildId: null,
|
||||
attachments: [],
|
||||
attachments: message.files?.map(file => ({
|
||||
id: nanoid(),
|
||||
userId: user.value!.id,
|
||||
topicId: '',
|
||||
messageId: '',
|
||||
fileId: file.id,
|
||||
createdAt: new Date(),
|
||||
file: {
|
||||
id: file.id,
|
||||
userId: user.value!.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size ?? 0,
|
||||
url: file.url,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
})) ?? [],
|
||||
deleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -75,6 +92,27 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
|
||||
|
||||
return startGeneration(model, undefined, route.params.id as string);
|
||||
};
|
||||
|
||||
const handleAddMessage = async (message: BaseMessage, role: 'user' | 'assistant', _model: ModelWithProvider | null) => {
|
||||
const user = useAuth().user;
|
||||
if (!user) {
|
||||
console.error('No user');
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = await createTopic(agent.value!.id);
|
||||
if (!topic) throw new Error('Failed to create topic');
|
||||
|
||||
const { sendMessage } = await useChat(topic.id, false);
|
||||
|
||||
const res = await sendMessage(message, undefined, role);
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to send message:', res.error);
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -108,7 +146,8 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
|
||||
|
||||
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
||||
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:agent="agent" :providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" />
|
||||
:allow-manual-role="true" :agent="agent" :providers="providers?.filter(p => p.enabled)"
|
||||
@submit="handleSubmit" @add-message="handleAddMessage" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,30 +55,32 @@ const changeSystemPrompt = async (e: Event) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-14 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
|
||||
<span class="i-mynaui-panel-left-open text-5"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 px-14 w-full h-full">
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
|
||||
<span class="text-16 i-mynaui-check-hexagon"></span>
|
||||
<div class="h-full flex flex-col overflow-hidden pb-4">
|
||||
<div class="h-14 flex items-center justify-between px-4 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
|
||||
<span class="i-mynaui-panel-left-open text-5"></span>
|
||||
</button>
|
||||
</div>
|
||||
<input v-model="name" @input="handleNameInput" placeholder="Agent Name..."
|
||||
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
|
||||
type="text" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full h-full mb-14">
|
||||
<label class="text-sm text-[var(--text-secondary)]">System Message</label>
|
||||
<textarea v-model="systemPrompt" placeholder="You are a helpful assistant."
|
||||
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
|
||||
@input="changeSystemPrompt"></textarea>
|
||||
|
||||
<div class="flex flex-col gap-4 px-4 md:px-14 w-full flex-1 min-h-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
|
||||
<span class="text-16 i-mynaui-check-hexagon"></span>
|
||||
</div>
|
||||
<input v-model="name" @input="handleNameInput" placeholder="Agent Name..."
|
||||
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
|
||||
type="text" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full flex-1 min-h-0">
|
||||
<label class="text-sm text-[var(--text-secondary)]">System Message</label>
|
||||
<textarea v-model="systemPrompt" placeholder="You are a helpful assistant."
|
||||
class="p-4 w-full flex-1 min-h-0 resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
|
||||
@input="changeSystemPrompt"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,27 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
|
||||
startGeneration(model);
|
||||
};
|
||||
|
||||
const handleAddMessage = async (message: BaseMessage, role: 'user' | 'assistant', _model: ModelWithProvider | null) => {
|
||||
inputValue.value = { content: '', fileIds: [] };
|
||||
|
||||
const res = await sendMessage(message, async () => {
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
}, role);
|
||||
if (!res.ok) {
|
||||
console.error('Failed to add message:', res.error);
|
||||
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
|
||||
if (chatInput) {
|
||||
inputValue.value = message;
|
||||
nextTick(() => {
|
||||
chatInput.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async (message: Message) => {
|
||||
if (!agent.value!.defaultModelId) {
|
||||
console.error('No model selected');
|
||||
@@ -292,8 +313,8 @@ console.log("full page render took", Date.now() - rootStart);
|
||||
|
||||
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
||||
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:loading="activeGeneration !== null" :agent="agent"
|
||||
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @cancel="handleCancel"
|
||||
:loading="activeGeneration !== null" :allow-manual-role="true" :agent="agent"
|
||||
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @add-message="handleAddMessage" @cancel="handleCancel"
|
||||
@resize="handleResize" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user