ab57af1f23
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.
195 lines
6.9 KiB
Vue
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>
|