Files
veridian/app/components/ChatInput.vue
T

484 lines
20 KiB
Vue

<script setup lang="ts">
import type { BaseMessage } from '~/composables/useChat';
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';
const { allModels } = await useModels();
const { updateAgent, patchAgentLocally } = await useAgents();
const inputHeight: Ref<string> = ref('auto');
const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null);
const inputRef = ref<HTMLTextAreaElement | null>(null);
let tempInput = '';
const files = ref<{
id: string;
name: string;
mimeType: string;
status: 'uploading' | 'uploaded' | 'error';
url: string;
progress: number;
retryCount: number;
}[]>([]);
const inputValue = defineModel<BaseMessage>({ required: false, default: { content: '', fileIds: [] } });
const textAreaValue = ref('');
watch(textAreaValue, (newValue) => {
inputValue.value.content = newValue;
});
watch(files, (newFiles) => {
inputValue.value.fileIds = newFiles.map(f => f.id);
});
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[];
}>();
const searchConfig = ref({
enabled: props.agent?.config?.search?.enabled ?? false,
maxResults: props.agent?.config?.search?.maxResults ?? 10,
rerank: props.agent?.config?.search?.rerank ?? false,
});
const toolsConfig = ref({
python: props.agent?.config?.tools?.python ?? false,
});
watch(() => props.agent?.config?.search, (val) => {
searchConfig.value = {
enabled: val?.enabled ?? false,
maxResults: val?.maxResults ?? 10,
rerank: val?.rerank ?? false,
};
}, { deep: true });
watch(() => props.agent?.config?.tools, (val) => {
toolsConfig.value = {
python: val?.python ?? false,
};
}, { deep: true });
const saveAgentConfig = async (partial: {
search?: typeof searchConfig.value;
tools?: typeof toolsConfig.value;
}) => {
if (!props.agent) return;
const currentConfig = props.agent.config ?? {};
const newConfig = {
...currentConfig,
...(partial.search !== undefined ? {
search: {
enabled: partial.search.enabled,
maxResults: partial.search.maxResults,
rerank: partial.search.rerank,
},
} : {}),
...(partial.tools !== undefined ? {
tools: {
python: partial.tools.python,
},
} : {}),
};
patchAgentLocally(props.agent.id, { config: newConfig });
updateAgent(props.agent.id, { config: newConfig });
};
const saveSearchConfig = async () => {
await saveAgentConfig({ search: searchConfig.value });
};
const saveToolsConfig = async () => {
await saveAgentConfig({ tools: toolsConfig.value });
};
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) => {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
console.log("pasted", item, item.type, item.getAsFile());
const file = item.getAsFile();
if (file !== null) {
event.preventDefault();
fileSelectorRef.value?.uploadFile(file);
}
}
};
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]!;
updateAgentDefaultModel(selectedModel.value.id);
}
};
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);
}
};
// 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 });
watch(() => props.agent?.defaultModelId, (newModelId) => {
const agentModel = allModels.value.find((m) => m.id === newModelId);
if (agentModel) {
selectedModel.value = agentModel;
return;
}
})
const handleSubmit = () => {
if (isUploading.value || hasFailedUploads.value) {
return;
}
if (props.loading) {
emit('cancel');
return;
}
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('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 = '';
}
};
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) {
if (inputRef.value === null) return;
// inset new line
const cursorPosition = inputRef.value.selectionStart;
if (cursorPosition === undefined) return;
if (cursorPosition !== inputRef.value.selectionEnd) return;
textAreaValue.value =
textAreaValue.value.slice(0, cursorPosition) + '\n' + textAreaValue.value.slice(cursorPosition);
nextTick(() => {
inputRef.value!.setSelectionRange(cursorPosition + 1, cursorPosition + 1);
});
return;
}
event.preventDefault();
handleSubmit();
}
};
const handleWindowKeyDown = async (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const isInteractive = document.activeElement?.closest('input, textarea, button, a, select, [contenteditable="true"]');
if (isInteractive) return;
if (!isInteractive) {
const isPrintable = event.key.length === 1;
if (isPrintable) {
inputRef.value?.focus();
} else if (event.key === 'Enter' && !props.loading) {
event.preventDefault();
handleSubmit();
inputRef.value?.focus();
}
}
};
const mirrorRef = ref<HTMLDivElement | null>(null);
const resizeTextArea = () => {
const textarea = inputRef.value;
const mirror = mirrorRef.value;
if (!textarea || !mirror) return;
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(Math.max(mirror.scrollHeight, minHeight), maxHeight);
inputHeight.value = `${height}px`;
});
};
watch(textAreaValue, resizeTextArea, { immediate: true });
let hasCommandKey = false;
if (import.meta.server) {
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 ?? '';
});
let resizeRafId: number | undefined;
const handleWindowResize = () => {
if (resizeRafId) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = undefined;
resizeTextArea();
});
}
onMounted(() => {
textAreaValue.value = tempInput;
document.addEventListener('keydown', handleWindowKeyDown);
window.addEventListener('resize', handleWindowResize);
inputRef.value?.addEventListener('paste', handlePaste);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleWindowKeyDown);
window.removeEventListener('resize', handleWindowResize);
if (resizeRafId !== undefined) cancelAnimationFrame(resizeRafId);
inputRef.value?.removeEventListener('paste', handlePaste);
});
</script>
<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-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)"
@retry="fileSelectorRef?.retryFile(file.id)" :key="file.id" :file="file" />
</div>
<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"
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
@keydown="handleKeyDown" :style="{ height: inputHeight }"
class="[scrollbar-width:none] w-full bg-transparent resize-none placeholder:text-[var(--text-tertiary)]"></textarea>
</div>
<div class="flex items-center justify-between gap-2">
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
<div class="flex flex-1 gap-1 min-w-0">
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
:providers="providers" />
<SearchSelector v-if="selectedModel?.capabilities.includes('tools')" :enabled="searchConfig.enabled"
:max-results="searchConfig.maxResults" :rerank="searchConfig.rerank"
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig() }"
@update:max-results="(v: number) => { searchConfig.maxResults = v; saveSearchConfig() }"
@update:rerank="(v: boolean) => { searchConfig.rerank = v; saveSearchConfig() }" />
<ToolSelector v-if="selectedModel?.capabilities.includes('tools')" :python="toolsConfig.python"
@update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig() }" />
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
</div>
<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 && !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>
</div>
</div>
</div>
</template>