533 lines
24 KiB
Vue
533 lines
24 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';
|
|
import { getComposerHeight } from '~/utils/composer-size';
|
|
|
|
const { allModels } = await useModels();
|
|
const { updateAgent, patchAgentLocally } = await useAgents();
|
|
|
|
const isDrawerOpen = useCookie<boolean>("chat:tools-drawer", { default: () => false });
|
|
watch(isDrawerOpen, () => {
|
|
emit("resize");
|
|
});
|
|
|
|
const inputHeight: Ref<string> = ref('48px');
|
|
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,
|
|
});
|
|
|
|
const toolsConfig = ref({
|
|
python: props.agent?.config?.tools?.python ?? false,
|
|
});
|
|
|
|
watch(() => props.agent?.config?.search, (val) => {
|
|
searchConfig.value = {
|
|
enabled: val?.enabled ?? 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,
|
|
},
|
|
} : {}),
|
|
...(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;
|
|
}
|
|
})
|
|
|
|
// sending needs a model, and there are none until a provider is set up
|
|
const noModels = computed(() => allModels.value.length === 0);
|
|
|
|
const handleSubmit = () => {
|
|
if (noModels.value) return;
|
|
|
|
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 MIRROR_PROPERTIES = [
|
|
'box-sizing', 'font-family', 'font-size', 'font-weight', 'font-style',
|
|
'line-height', 'letter-spacing', 'word-spacing', 'text-align', 'text-indent',
|
|
'text-transform', 'tab-size', 'padding-top', 'padding-bottom', 'padding-left',
|
|
'padding-right', 'border-top-width', 'border-bottom-width', 'border-left-width',
|
|
'border-right-width', 'border-style', 'white-space', 'overflow-wrap', 'word-break',
|
|
] as const;
|
|
let sizingRaf: number | undefined;
|
|
let sizingObserver: ResizeObserver | undefined;
|
|
let typographyObserver: MutationObserver | undefined;
|
|
let sizingDirty = true;
|
|
let mirrorWidth = '';
|
|
let lineHeight = 24;
|
|
let padding = 0;
|
|
let borders = 0;
|
|
let borderBox = true;
|
|
|
|
const resizeTextArea = () => {
|
|
if (import.meta.server || sizingRaf !== undefined) return;
|
|
sizingRaf = requestAnimationFrame(() => {
|
|
sizingRaf = undefined;
|
|
const textarea = inputRef.value;
|
|
const mirror = mirrorRef.value;
|
|
if (!textarea || !mirror) return;
|
|
|
|
if (sizingDirty) {
|
|
// Finish style reads before changing the isolated measurement box.
|
|
const styles = getComputedStyle(textarea);
|
|
const values = MIRROR_PROPERTIES.map(property => styles.getPropertyValue(property));
|
|
mirrorWidth = styles.width;
|
|
lineHeight = Number.parseFloat(styles.lineHeight) || Number.parseFloat(styles.fontSize) * 1.2;
|
|
padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom);
|
|
borders = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth);
|
|
borderBox = styles.boxSizing === 'border-box';
|
|
MIRROR_PROPERTIES.forEach((property, index) => mirror.style.setProperty(property, values[index]!));
|
|
mirror.style.width = mirrorWidth;
|
|
sizingDirty = false;
|
|
}
|
|
|
|
// Preserve the final empty line without adding an extra line to ordinary text.
|
|
mirror.textContent = textarea.value + '\u200b';
|
|
const height = `${getComposerHeight(mirror.scrollHeight, lineHeight, padding, borders, borderBox)}px`;
|
|
if (height === inputHeight.value) return;
|
|
|
|
// Capture bottom-follow intent before applying the new height.
|
|
emit('resize');
|
|
inputHeight.value = height;
|
|
});
|
|
};
|
|
|
|
watch(textAreaValue, resizeTextArea, { flush: 'post' });
|
|
|
|
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 ?? '';
|
|
});
|
|
|
|
const handleWindowResize = () => {
|
|
sizingDirty = true;
|
|
resizeTextArea();
|
|
};
|
|
|
|
onMounted(() => {
|
|
textAreaValue.value = tempInput;
|
|
document.addEventListener('keydown', handleWindowKeyDown);
|
|
window.addEventListener('resize', handleWindowResize);
|
|
inputRef.value?.addEventListener('paste', handlePaste);
|
|
let observedWidth: number | undefined;
|
|
sizingObserver = new ResizeObserver(([entry]) => {
|
|
if (!entry || entry.contentRect.width === observedWidth) return;
|
|
observedWidth = entry.contentRect.width;
|
|
handleWindowResize();
|
|
});
|
|
if (inputRef.value) sizingObserver.observe(inputRef.value);
|
|
typographyObserver = new MutationObserver(handleWindowResize);
|
|
typographyObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] });
|
|
document.fonts.addEventListener('loadingdone', handleWindowResize);
|
|
handleWindowResize();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleWindowKeyDown);
|
|
window.removeEventListener('resize', handleWindowResize);
|
|
if (sizingRaf !== undefined) cancelAnimationFrame(sizingRaf);
|
|
sizingObserver?.disconnect();
|
|
typographyObserver?.disconnect();
|
|
document.fonts.removeEventListener('loadingdone', handleWindowResize);
|
|
inputRef.value?.removeEventListener('paste', handlePaste);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div :class="['w-full flex flex-col max-h-full', $attrs.class]">
|
|
<div ref="mirrorRef" data-chat-measurement aria-hidden="true"
|
|
class="fixed top-0 left-0 pointer-events-none invisible overflow-hidden h-0 [contain:layout_style_paint]"></div>
|
|
<div class="relative z-10 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"></AttachmentPreview>
|
|
</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 items-center gap-1 min-w-0">
|
|
<button v-if="selectedModel?.capabilities.includes('tools')"
|
|
type="button"
|
|
@click="isDrawerOpen = !isDrawerOpen"
|
|
class="sm:hidden relative flex items-center justify-center h-8.5 w-8.5 shrink-0 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200"
|
|
:aria-label="isDrawerOpen ? 'Close tools drawer' : 'Open tools drawer'"
|
|
:aria-expanded="isDrawerOpen">
|
|
<span class="i-mynaui-chevron-down text-5 text-[var(--text-secondary)] transition-transform duration-200"
|
|
:class="{ 'rotate-180': isDrawerOpen }"></span>
|
|
<span v-if="!isDrawerOpen && (searchConfig.enabled || toolsConfig.python)"
|
|
class="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--color-accent)]"></span>
|
|
</button>
|
|
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
|
|
:providers="providers"></ModelSelector>
|
|
<div v-if="selectedModel?.capabilities.includes('tools')" class="hidden sm:flex items-center gap-1">
|
|
<SearchSelector :enabled="searchConfig.enabled"
|
|
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig(); }"></SearchSelector>
|
|
<ToolSelector :python="toolsConfig.python"
|
|
@update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig(); }"></ToolSelector>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files"></FileSelector>
|
|
<div v-if="allowManualRole" class="flex items-center">
|
|
<button :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
|
|
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
|
|
: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="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
|
|
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
|
|
: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>
|
|
|
|
<div v-if="selectedModel?.capabilities.includes('tools')"
|
|
class="sm:hidden relative z-0 mx-auto w-[calc(100%-4px)] transition-[height] duration-250 ease-[cubic-bezier(0.4,_0,_0.2,_1)]"
|
|
:class="isDrawerOpen ? 'h-[44px]' : 'h-0'">
|
|
<div class="absolute -top-4 left-0 right-0 pt-5 px-3 pb-1.5 rounded-b-2xl border-x border-b border-[var(--color-border)] bg-[var(--bg-surface)] flex items-center gap-1.5 transition-[transform,opacity] duration-250 ease-[cubic-bezier(0.4,_0,_0.2,_1)]"
|
|
:class="isDrawerOpen ? 'translate-y-0 opacity-100' : '-translate-y-[44px] opacity-0 pointer-events-none'">
|
|
<SearchSelector :enabled="searchConfig.enabled"
|
|
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig(); }"></SearchSelector>
|
|
<ToolSelector :python="toolsConfig.python"
|
|
@update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig(); }"></ToolSelector>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|