Files
veridian/app/components/ChatInput.vue
T
zoeissleeping ab57af1f23 feat: show page titles on agent and topic pages
This commit moves the actions bar out of the default template and into the
individual pages so that they can display their titles at the top. this
commit also has pages set the meta title so tabs are labeled nicely.
2026-02-24 16:33:32 +00:00

195 lines
6.9 KiB
Vue

<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Agent } from '~/composables/useAgents';
const { allModels } = useModels();
const inputHeight: Ref<string> = ref('auto');
const inputRef = ref<HTMLTextAreaElement | null>(null);
let tempInput = '';
const inputValue = defineModel<string>({ required: false, default: '' });
const triplit = useTriplitClient();
const emit = defineEmits<{
submit: [value: string, model: ModelWithProvider | null];
cancel: [];
}>();
const props = defineProps<{
loading?: boolean;
agent: Agent | null;
providers?: ProviderWithModels[];
}>();
const selectedModel = ref<ModelWithProvider | null>(null);
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 {
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 });
watch(() => props.agent?.defaultModelId, (newModelId) => {
const agentModel = allModels.value.find((m) => m.id === newModelId);
if (agentModel) {
selectedModel.value = agentModel;
return;
}
})
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);
return;
}
event.preventDefault();
handleSubmit();
}
};
const handleWindowKeyDown = async (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey) {
return;
}
if (document.activeElement === document.body) {
// redirect all standard keyboard input to the input field
inputRef.value?.focus();
}
};
watch(inputValue, async () => {
const textarea = inputRef.value;
if (!textarea) return;
inputHeight.value = 'auto';
await nextTick();
const lineHeight = 24;
const maxLines = 10;
const maxHeight = maxLines * lineHeight;
const newHeight = textarea.scrollHeight;
if (newHeight > maxHeight) {
inputHeight.value = `${maxHeight}px`;
} else {
inputHeight.value = `${newHeight}px`;
}
}, { 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 ?? '';
});
onMounted(() => {
inputValue.value = tempInput;
document.addEventListener('keydown', handleWindowKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleWindowKeyDown);
});
</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(--bg-container)]
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
<div class="flex-1 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="inputValue" 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 text-[0.95em] 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-1">
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers" />
</div>
<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(--text-dim)]',
loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]',
]">
<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>