Files
veridian/app/components/ChatInput.vue
T

183 lines
6.3 KiB
Vue

<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 { allModels } = await useModels();
const inputRef = ref<HTMLTextAreaElement | null>(null);
let tempInput = '';
const inputValue = ref('');
const triplit = useTriplitClient();
const emit = defineEmits<{
submit: [value: string, model: ModelWithProvider | null];
cancel: [];
}>();
const props = defineProps<{
loading?: boolean;
agent?: Entity<typeof schema, 'agents'>;
providers?: ProviderWithModels[];
}>();
// Model selection state
const selectedModel = ref<ModelWithProvider | null>(null);
// 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);
}
};
// 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
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);
await nextTick();
handleInput();
return;
}
event.preventDefault();
handleSubmit();
}
};
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) {
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;
nextTick(() => {
handleInput();
});
});
</script>
<template>
<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 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.`"
@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 items-center justify-between gap-2">
<div class="flex-1">
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers">
</ModelSelector>
</div>
<!-- Send/Stop Button -->
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
:class="[
'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)]'
: 'text-[var(--color-highlight-high)]',
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]',
]">
<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>
</div>
</template>