New version

This commit is contained in:
Saifeddine ALOUI 2025-03-31 00:56:30 +02:00
parent 8f8dd8f39b
commit 2e1b921fc5
13 changed files with 4799 additions and 0 deletions

View File

@ -0,0 +1,49 @@
<template>
<nav class="p-4">
<h2 class="text-lg font-semibold mb-4 px-2">Settings</h2>
<ul>
<li v-for="section in sections" :key="section.id" class="mb-1">
<button
@click="$emit('update:activeSection', section.id)"
:class="[
'w-full flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors duration-150',
activeSection === section.id
? 'bg-primary text-white shadow-sm'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
]"
>
<i :data-feather="section.icon" class="w-5 h-5 mr-3 flex-shrink-0"></i>
<span>{{ section.name }}</span>
</button>
</li>
</ul>
</nav>
</template>
<script setup>
import { defineProps, defineEmits, onMounted, nextTick } from 'vue';
import feather from 'feather-icons';
const props = defineProps({
sections: {
type: Array,
required: true,
},
activeSection: {
type: String,
required: true,
},
});
defineEmits(['update:activeSection']);
onMounted(() => {
nextTick(() => {
feather.replace();
});
});
</script>
<style scoped>
/* Add any specific sidebar styles if needed */
</style>

View File

@ -0,0 +1,42 @@
// src/components/ToggleSwitch.vue
<template>
<label :for="id" class="relative inline-flex items-center cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': disabled }">
<input
type="checkbox"
:id="id"
:checked="checked"
:disabled="disabled"
@change="$emit('update:checked', $event.target.checked)"
class="sr-only peer"
>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<!-- Optional: Add slot for text next to the toggle if needed -->
<slot></slot>
</label>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
id: {
type: String,
required: true,
},
checked: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
}
});
defineEmits(['update:checked']);
</script>
<style scoped>
/* Basic styling is done with Tailwind classes directly in the template.
No additional scoped styles needed for this basic implementation. */
</style>

View File

@ -0,0 +1,394 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-gray-200 dark:border-gray-700 pb-3 mb-4">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2 sm:mb-0">
Binding Zoo
</h2>
<!-- Current Binding Display -->
<div v-if="currentBindingInfo" class="flex items-center gap-2 text-sm font-medium p-2 bg-primary-light dark:bg-primary-dark/20 rounded-md border border-primary-dark/30">
<img :src="currentBindingInfo.icon" @error="imgPlaceholder" class="w-6 h-6 rounded-full object-cover flex-shrink-0" alt="Current Binding Icon">
<span>Active: <span class="font-semibold">{{ currentBindingInfo.name }}</span></span>
<button @click="handleSettings(config.binding_name)" class="ml-2 p-1 rounded-full hover:bg-primary-dark/20" title="Configure Active Binding">
<i data-feather="settings" class="w-4 h-4"></i>
</button>
<button @click="handleReload(config.binding_name)" class="ml-1 p-1 rounded-full hover:bg-primary-dark/20" title="Reload Active Binding">
<i data-feather="refresh-cw" class="w-4 h-4"></i>
</button>
</div>
<div v-else class="text-sm font-medium text-red-600 dark:text-red-400 p-2 bg-red-50 dark:bg-red-900/20 rounded-md border border-red-300 dark:border-red-600">
No binding selected!
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Bindings are the engines that run the AI models. Select an installed binding to enable model selection and generation.
</p>
<!-- Search and Sort Controls -->
<div class="flex flex-col sm:flex-row gap-4 mb-4">
<!-- Search Input -->
<div class="relative flex-grow">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-feather="search" class="w-5 h-5 text-gray-400"></i>
</div>
<input
type="search"
v-model="searchTerm"
placeholder="Search bindings by name or author..."
class="input-field pl-10 w-full"
/>
</div>
<!-- Sort Select -->
<div class="flex-shrink-0">
<label for="binding-sort" class="sr-only">Sort bindings by</label>
<select id="binding-sort" v-model="sortOption" class="input-field">
<option value="name">Sort by Name</option>
<option value="author">Sort by Author</option>
<!-- Add more options if needed, e.g., popularity, last updated -->
</select>
</div>
</div>
<!-- Bindings Grid -->
<div v-if="isLoadingBindings" class="flex justify-center items-center p-10">
<svg aria-hidden="true" class="w-8 h-8 text-gray-300 animate-spin dark:text-gray-600 fill-primary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
<span class="ml-2 text-gray-500 dark:text-gray-400">Loading bindings...</span>
</div>
<div v-else-if="sortedBindings.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-10">
No bindings found{{ searchTerm ? ' matching "' + searchTerm + '"' : '' }}.
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<BindingEntry
v-for="binding in sortedBindings"
:key="binding.folder"
:binding="binding"
:is-selected="config.binding_name === binding.folder"
@select="handleSelect(binding)"
@install="handleInstall(binding)"
@uninstall="handleUninstall(binding)"
@reinstall="handleReinstall(binding)"
@settings="handleSettings(binding.folder)"
@reload="handleReload(binding.folder)"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, defineProps, defineEmits, watch } from 'vue';
import feather from 'feather-icons';
import BindingEntry from '@/components/BindingEntry.vue'; // Assuming this component exists
import defaultBindingIcon from "@/assets/default_binding.png"; // Path to your default icon
// Props
const props = defineProps({
config: { type: Object, required: true },
loading: { type: Boolean, default: false }, // Parent loading state
api_post_req: { type: Function, required: true },
api_get_req: { type: Function, required: true },
show_toast: { type: Function, required: true },
show_yes_no_dialog: { type: Function, required: true },
show_universal_form: { type: Function, required: true },
refresh_config: { type: Function, required: true }, // Function to trigger parent config refresh
client_id: { type: String, required: true }
});
// Emits
const emit = defineEmits(['update:setting', 'settings-changed']); // Emit settings-changed if needed locally
// State
const bindings = ref([]);
const isLoadingBindings = ref(false);
const isLoadingAction = ref(false); // For specific actions like install/uninstall
const sortOption = ref('name'); // 'name' or 'author'
const searchTerm = ref('');
// --- Computed ---
const currentBindingInfo = computed(() => {
if (!props.config || !props.config.binding_name || bindings.value.length === 0) {
return null;
}
const current = bindings.value.find(b => b.folder === props.config.binding_name);
return current ? { name: current.name, icon: current.icon || defaultBindingIcon } : null;
});
const sortedBindings = computed(() => {
if (!bindings.value) return [];
let filtered = [...bindings.value];
// Filter by search term
if (searchTerm.value) {
const lowerSearch = searchTerm.value.toLowerCase();
filtered = filtered.filter(b =>
b.name?.toLowerCase().includes(lowerSearch) ||
b.author?.toLowerCase().includes(lowerSearch) ||
b.description?.toLowerCase().includes(lowerSearch) ||
b.folder?.toLowerCase().includes(lowerSearch)
);
}
// Sort
filtered.sort((a, b) => {
// 1. Installed first
if (a.installed && !b.installed) return -1;
if (!a.installed && b.installed) return 1;
// 2. Secondary sort option
if (sortOption.value === 'name') {
return (a.name || '').localeCompare(b.name || '');
} else if (sortOption.value === 'author') {
return (a.author || '').localeCompare(b.author || '');
}
// Add more sort options here if needed
return 0; // Keep original order if secondary sort doesn't apply
});
return filtered;
});
// --- Methods ---
const fetchBindings = async () => {
isLoadingBindings.value = true;
try {
const response = await props.api_get_req('list_bindings');
// Add isProcessing state to each binding for install/uninstall UI feedback
bindings.value = response.map(b => ({ ...b, isProcessing: false })) || [];
} catch (error) {
props.show_toast("Failed to load bindings.", 4, false);
console.error("Error fetching bindings:", error);
bindings.value = [];
} finally {
isLoadingBindings.value = false;
nextTick(feather.replace);
}
};
const setBindingProcessing = (folder, state) => {
const index = bindings.value.findIndex(b => b.folder === folder);
if (index !== -1) {
bindings.value[index].isProcessing = state;
}
};
const handleSelect = (binding) => {
if (!binding.installed) {
props.show_toast(`Binding "${binding.name}" is not installed.`, 3, false);
return;
}
if (props.config.binding_name !== binding.folder) {
emit('update:setting', { key: 'binding_name', value: binding.folder });
// Optionally reset model name here or let parent handle it
emit('update:setting', { key: 'model_name', value: null });
props.show_toast(`Selected binding: ${binding.name}`, 3, true);
}
};
const handleInstall = async (binding) => {
let proceed = true;
if (binding.disclaimer) {
proceed = await props.show_yes_no_dialog(`Disclaimer for ${binding.name}:\n\n${binding.disclaimer}\n\nDo you want to proceed with installation?`, 'Proceed', 'Cancel');
}
if (!proceed) return;
setBindingProcessing(binding.folder, true);
isLoadingAction.value = true; // Maybe use a global loading indicator from parent?
try {
const response = await props.api_post_req('install_binding', { name: binding.folder });
if (response && response.status) {
props.show_toast(`Binding "${binding.name}" installed successfully! Restart recommended.`, 5, true);
// Refresh bindings list to show installed status
await fetchBindings();
// Optionally prompt for restart or reload page
// props.show_message_box("It is advised to reboot the application after installing a binding.\nPage will refresh in 5s.")
// setTimeout(()=>{window.location.href = "/"},5000) ;
} else {
props.show_toast(`Failed to install binding "${binding.name}": ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error installing binding "${binding.name}": ${error.message}`, 4, false);
console.error(`Error installing ${binding.folder}:`, error);
} finally {
setBindingProcessing(binding.folder, false);
isLoadingAction.value = false;
}
};
const handleUninstall = async (binding) => {
const yes = await props.show_yes_no_dialog(`Are you sure you want to uninstall the binding "${binding.name}"?\nThis will remove its files.`, 'Uninstall', 'Cancel');
if (!yes) return;
setBindingProcessing(binding.folder, true);
isLoadingAction.value = true;
try {
const response = await props.api_post_req('unInstall_binding', { name: binding.folder });
if (response && response.status) {
props.show_toast(`Binding "${binding.name}" uninstalled successfully!`, 4, true);
await fetchBindings(); // Refresh list
// If the uninstalled binding was selected, clear it
if (props.config.binding_name === binding.folder) {
emit('update:setting', { key: 'binding_name', value: null });
emit('update:setting', { key: 'model_name', value: null });
}
} else {
props.show_toast(`Failed to uninstall binding "${binding.name}": ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error uninstalling binding "${binding.name}": ${error.message}`, 4, false);
console.error(`Error uninstalling ${binding.folder}:`, error);
} finally {
setBindingProcessing(binding.folder, false);
isLoadingAction.value = false;
}
};
const handleReinstall = async (binding) => {
const yes = await props.show_yes_no_dialog(`Are you sure you want to reinstall the binding "${binding.name}"?\nThis will overwrite existing files.`, 'Reinstall', 'Cancel');
if (!yes) return;
setBindingProcessing(binding.folder, true);
isLoadingAction.value = true;
try {
const response = await props.api_post_req('reinstall_binding', { name: binding.folder });
if (response && response.status) {
props.show_toast(`Binding "${binding.name}" reinstalled successfully! Restart recommended.`, 5, true);
await fetchBindings(); // Refresh list (status might not change visually immediately)
} else {
props.show_toast(`Failed to reinstall binding "${binding.name}": ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error reinstalling binding "${binding.name}": ${error.message}`, 4, false);
console.error(`Error reinstalling ${binding.folder}:`, error);
} finally {
setBindingProcessing(binding.folder, false);
isLoadingAction.value = false;
}
};
const handleSettings = async (bindingFolder) => {
if (!bindingFolder) return;
// Needs to fetch settings specifically for the *active* binding,
// even if clicking the settings button on an *inactive* but selected binding in the list.
// The backend endpoint usually gets settings for the currently loaded one.
// We might need a way to get settings for *any* installed binding if required.
// Assuming '/get_active_binding_settings' gets settings for props.config.binding_name
if (bindingFolder !== props.config.binding_name) {
props.show_toast(`Please select the binding "${bindingFolder}" first to configure its active settings.`, 4, false);
return; // Or try to fetch settings for the specific folder if backend supports it
}
isLoadingAction.value = true;
try {
const settingsData = await props.api_post_req('get_active_binding_settings');
if (settingsData && Object.keys(settingsData).length > 0) {
const bindingName = bindings.value.find(b => b.folder === bindingFolder)?.name || bindingFolder;
const result = await props.show_universal_form(settingsData, `Binding Settings - ${bindingName}`, "Save", "Cancel");
// If form was submitted (not cancelled)
const setResponse = await props.api_post_req('set_active_binding_settings', { settings: result });
if (setResponse && setResponse.status) {
props.show_toast(`Settings for "${bindingName}" updated. Reloading binding...`, 4, true);
// Attempt to apply the settings by reloading the binding
await props.api_post_req('update_binding_settings'); // Tell backend to commit potentially cached settings
// Optional: Force page reload or more graceful update
// await props.refresh_config(); // Refresh config may revert visual changes until saved/applied
props.show_toast(`Binding "${bindingName}" reloaded with new settings.`, 4, true);
// window.location.href = "/"; // Force reload if necessary
} else {
props.show_toast(`Failed to update settings for "${bindingName}": ${setResponse?.error || 'Unknown error'}`, 4, false);
}
} else {
props.show_toast(`Binding "${bindingFolder}" has no configurable settings.`, 4, false);
}
} catch (error) {
props.show_toast(`Error accessing settings for "${bindingFolder}": ${error.message}`, 4, false);
console.error(`Error getting/setting settings for ${bindingFolder}:`, error);
} finally {
isLoadingAction.value = false;
}
};
const handleReload = async (bindingFolder) => {
if (!bindingFolder || bindingFolder !== props.config.binding_name) {
props.show_toast(`Binding "${bindingFolder}" is not currently active.`, 3, false);
return;
}
isLoadingAction.value = true;
props.show_toast(`Reloading binding "${bindingFolder}"...`, 3, true);
try {
const response = await props.api_post_req('reload_binding', { name: bindingFolder }); // Assuming name = folder
if (response && response.status) {
props.show_toast(`Binding "${bindingFolder}" reloaded successfully.`, 4, true);
// Might need to refresh models list if reload affects available models
} else {
props.show_toast(`Failed to reload binding "${bindingFolder}": ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error reloading binding "${bindingFolder}": ${error.message}`, 4, false);
console.error(`Error reloading ${bindingFolder}:`, error);
} finally {
isLoadingAction.value = false;
}
};
const imgPlaceholder = (event) => {
event.target.src = defaultBindingIcon;
};
// Lifecycle Hooks
onMounted(() => {
fetchBindings();
});
onUpdated(() => {
// Use this cautiously
nextTick(() => {
feather.replace();
});
});
</script>
<style scoped>
/* Using shared styles */
.input-field {
@apply block w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 disabled:opacity-50; /* Corrected focus */
}
/* Updated Button Styles in BindingZoo */
.button-primary-sm {
/* Assuming 'primary' is blue-600 */
@apply button-base-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500;
}
.button-secondary-sm {
@apply button-base-sm text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400;
}
.button-success-sm {
@apply button-base-sm text-white bg-green-600 hover:bg-green-700 focus:ring-green-500;
}
/* Base definitions if not global */
.button-base-sm {
@apply inline-flex items-center justify-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 transition-colors duration-150;
}
.dark .button-secondary-sm {
@apply focus:ring-offset-gray-800;
}
/* Add specific styles if needed */
.binding-grid-enter-active,
.binding-grid-leave-active {
transition: all 0.5s ease;
}
.binding-grid-enter-from,
.binding-grid-leave-to {
opacity: 0;
transform: translateY(20px);
}
.binding-grid-leave-active {
position: absolute; /* Optional: for smoother leave transitions */
}
</style>

View File

@ -0,0 +1,714 @@
// src/components/settings_components/DataManagementSettings.vue
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 pb-2">
Data Management
</h2>
<!-- Data Lakes Configuration -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Data Lakes</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Configure data sources (vector databases) that LoLLMs can query for information retrieval (RAG).
</p>
<!-- Data Lakes List -->
<div class="space-y-4">
<div v-if="!config.datalakes || config.datalakes.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-4">
No Data Lakes configured.
</div>
<div v-for="(source, index) in config.datalakes" :key="`datalake-${index}`"
class="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg shadow-sm border border-gray-200 dark:border-gray-600 space-y-4 relative group"
>
<!-- Remove Button (Top Right) -->
<button
@click="removeDataLake(index)"
class="absolute top-2 right-2 p-1 rounded-full text-red-500 hover:bg-red-100 dark:hover:bg-red-900/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
title="Remove Data Lake"
>
<i data-feather="x-circle" class="w-5 h-5"></i>
</button>
<!-- Main Controls Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Data Lake Alias -->
<div>
<label :for="`dl-alias-${index}`" class="setting-label-inline">Alias</label>
<input
type="text"
:id="`dl-alias-${index}`"
:value="source.alias"
@input="updateDataLake(index, 'alias', $event.target.value)"
class="input-field-sm w-full"
placeholder="Enter alias (e.g., 'Project Docs')"
>
</div>
<!-- Data Lake Type -->
<div>
<label :for="`dl-type-${index}`" class="setting-label-inline">Type</label>
<select
:id="`dl-type-${index}`"
required
:value="source.type"
@change="updateDataLake(index, 'type', $event.target.value)"
class="input-field-sm w-full"
>
<option value="lollmsvectordb">LoLLMs VectorDB</option>
<option value="lightrag">LightRAG</option>
<option value="elasticsearch">Elasticsearch</option>
</select>
</div>
<!-- Conditional URL/Path Input -->
<div class="md:col-span-2">
<label :for="`dl-pathurl-${index}`" class="setting-label-inline">
{{ source.type === 'lollmsvectordb' ? 'Database Path' : (source.type === 'lightrag' ? 'LightRAG URL' : 'Elasticsearch URL') }}
</label>
<input
type="text"
:id="`dl-pathurl-${index}`"
:value="source.type === 'lollmsvectordb' ? source.path : source.url"
@input="updateDataLake(index, source.type === 'lollmsvectordb' ? 'path' : 'url', $event.target.value)"
class="input-field-sm w-full"
:placeholder="source.type === 'lollmsvectordb' ? 'Path to database folder' : 'http://host:port/'"
>
</div>
<!-- API Key (conditional) -->
<div v-if="source.type === 'lightrag' || source.type === 'elasticsearch'" class="md:col-span-2">
<label :for="`dl-key-${index}`" class="setting-label-inline">API Key (Optional)</label>
<input
type="password"
:id="`dl-key-${index}`"
:value="source.key"
@input="updateDataLake(index, 'key', $event.target.value)"
class="input-field-sm w-full"
placeholder="Enter API key if required"
>
</div>
</div>
<!-- Actions Row -->
<div class="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-gray-300 dark:border-gray-600">
<!-- Mounted Toggle -->
<div class="flex items-center space-x-2">
<ToggleSwitch
:id="`dl-mounted-${index}`"
:checked="source.mounted"
@update:checked="updateDataLake(index, 'mounted', $event)"
/>
<label :for="`dl-mounted-${index}`" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Mounted
</label>
</div>
<!-- Type Specific Actions -->
<div class="flex flex-wrap gap-2">
<!-- Lollms VectorDB Actions -->
<template v-if="source.type === 'lollmsvectordb'">
<button @click="vectorizeFolder(index)" class="button-secondary-sm" title="Vectorize or re-vectorize the selected folder">
<i data-feather="refresh-cw" class="w-4 h-4 mr-1"></i> Vectorize
</button>
<button @click="selectLollmsVectordbFolder(index)" class="button-primary-sm" title="Select folder containing documents to vectorize">
<i data-feather="folder-plus" class="w-4 h-4 mr-1"></i> Select Folder
</button>
</template>
<!-- LightRAG Actions -->
<template v-if="source.type === 'lightrag'">
<button @click="triggerFileInput(index)" class="button-success-sm" title="Upload supported files (.txt, .md, .pdf, .docx, .pptx, .xlsx)">
<i data-feather="upload" class="w-4 h-4 mr-1"></i> Upload Files
</button>
<input type="file" :ref="el => fileInputs[index] = el" @change="handleFileUpload($event, index)"
accept=".txt,.md,.pdf,.docx,.pptx,.xlsx" class="hidden" multiple />
<!-- Add Check Status button later if needed -->
</template>
</div>
</div>
</div>
</div>
<!-- Add New Data Lake Button -->
<div class="pt-4">
<button @click="addDataLake" class="button-primary w-full md:w-auto">
<i data-feather="plus-circle" class="w-5 h-5 mr-2"></i> Add New Data Lake
</button>
</div>
</section>
<!-- Database Servers Configuration -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Self-Hosted RAG Servers</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Configure and manage local RAG server instances (like LightRAG) running on your machine.
</p>
<!-- Servers List -->
<div class="space-y-4">
<div v-if="!config.rag_local_services || config.rag_local_services.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-4">
No RAG Servers configured.
</div>
<div v-for="(server, index) in config.rag_local_services" :key="`server-${index}`"
class="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg shadow-sm border border-gray-200 dark:border-gray-600 space-y-4 relative group"
>
<!-- Remove Button -->
<button
@click="removeDatabaseService(index)"
class="absolute top-2 right-2 p-1 rounded-full text-red-500 hover:bg-red-100 dark:hover:bg-red-900/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
title="Remove Server"
>
<i data-feather="x-circle" class="w-5 h-5"></i>
</button>
<!-- Server Controls Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Server Alias -->
<div>
<label :for="`srv-alias-${index}`" class="setting-label-inline">Alias</label>
<input type="text" :id="`srv-alias-${index}`" :value="server.alias" @input="updateServer(index, 'alias', $event.target.value)" class="input-field-sm w-full" placeholder="Server Alias">
</div>
<!-- Server Type -->
<div>
<label :for="`srv-type-${index}`" class="setting-label-inline">Type</label>
<select :id="`srv-type-${index}`" required :value="server.type" @change="updateServer(index, 'type', $event.target.value)" class="input-field-sm w-full">
<option value="lightrag">LightRAG</option>
<!-- <option value="elasticsearch">Elasticsearch</option> -->
</select>
</div>
<!-- Server URL -->
<div class="md:col-span-2">
<label :for="`srv-url-${index}`" class="setting-label-inline">Server URL</label>
<input type="text" :id="`srv-url-${index}`" :value="server.url" @input="updateServer(index, 'url', $event.target.value)" class="input-field-sm w-full" placeholder="http://localhost:port">
</div>
<!-- API Key -->
<div>
<label :for="`srv-key-${index}`" class="setting-label-inline">API Key (Optional)</label>
<input type="password" :id="`srv-key-${index}`" :value="server.key" @input="updateServer(index, 'key', $event.target.value)" class="input-field-sm w-full" placeholder="API Key if needed">
</div>
<!-- Start at Startup -->
<div class="flex items-end pb-1">
<div class="flex items-center space-x-2">
<ToggleSwitch :id="`srv-startup-${index}`" :checked="server.start_at_startup" @update:checked="updateServer(index, 'start_at_startup', $event)" />
<label :for="`srv-startup-${index}`" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">Start at Startup</label>
</div>
</div>
<!-- Input Folder -->
<div>
<label :for="`srv-input-${index}`" class="setting-label-inline">Input Folder Path</label>
<div class="flex">
<input type="text" :id="`srv-input-${index}`" :value="server.input_path" @input="updateServer(index, 'input_path', $event.target.value)" class="input-field-sm w-full rounded-r-none" placeholder="Path to watch for new files">
<button @click="selectLightragFolder(index, 'input')" class="button-secondary-sm rounded-l-none flex-shrink-0" title="Select Input Folder"><i data-feather="folder" class="w-4 h-4"></i></button>
</div>
</div>
<!-- Working Folder -->
<div>
<label :for="`srv-work-${index}`" class="setting-label-inline">Working Folder Path</label>
<div class="flex">
<input type="text" :id="`srv-work-${index}`" :value="server.working_path" @input="updateServer(index, 'working_path', $event.target.value)" class="input-field-sm w-full rounded-r-none" placeholder="Path for database files">
<button @click="selectLightragFolder(index, 'output')" class="button-secondary-sm rounded-l-none flex-shrink-0" title="Select Working Folder"><i data-feather="folder" class="w-4 h-4"></i></button>
</div>
</div>
</div>
<!-- Server Actions & Status -->
<div class="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-gray-300 dark:border-gray-600">
<div class="flex items-center gap-2">
<span :class="['w-3 h-3 rounded-full', serverStatuses[index]?.dotClass || 'bg-gray-400']" :title="serverStatuses[index]?.title || 'Unknown'"></span>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ serverStatuses[index]?.text || 'Status Unknown' }}</span>
<button @click="checkServerHealth(index)" class="button-secondary-sm p-1" title="Check Server Status" :disabled="serverStatuses[index]?.loading">
<i data-feather="refresh-cw" :class="['w-4 h-4', serverStatuses[index]?.loading ? 'animate-spin' : '']"></i>
</button>
</div>
<div class="flex flex-wrap gap-2">
<button @click="startRagServer(index)" class="button-success-sm" title="Start this RAG server instance">
<i data-feather="play" class="w-4 h-4 mr-1"></i> Start Server
</button>
<button v-if="server.type === 'lightrag' && serverStatuses[index]?.status === 'healthy'" @click="showLightRagWebUI(index)" class="button-primary-sm" title="Open LightRAG Web UI">
<i data-feather="external-link" class="w-4 h-4 mr-1"></i> Show WebUI
</button>
</div>
</div>
<!-- Status Details (Example for LightRAG) -->
<div v-if="server.type === 'lightrag' && serverStatuses[index] && serverStatuses[index].status === 'healthy' && serverStatuses[index].details" class="text-xs text-gray-500 dark:text-gray-400 space-y-1 pt-2 border-t border-dashed border-gray-300 dark:border-gray-600 mt-2">
<div><b>Indexed Files:</b> {{ serverStatuses[index].details.indexed_files_count ?? 'N/A' }}</div>
<div><b>Model:</b> {{ serverStatuses[index].details.configuration?.llm_model || 'N/A' }}</div>
<div><b>Embedding:</b> {{ serverStatuses[index].details.configuration?.embedding_model || 'N/A' }}</div>
<!-- Add more details as needed -->
</div>
</div>
</div>
<!-- Add New Server Button -->
<div class="pt-4">
<button @click="addDatabaseService" class="button-primary w-full md:w-auto">
<i data-feather="plus-circle" class="w-5 h-5 mr-2"></i> Add New RAG Server
</button>
</div>
</section>
<!-- LollmsVectordb General Configuration -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">LoLLMs VectorDB Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- RAG Vectorizer -->
<div>
<label for="rag_vectorizer" class="setting-label-inline">Vectorizer Engine</label>
<select id="rag_vectorizer" required :value="config.rag_vectorizer" @change="updateValue('rag_vectorizer', $event.target.value)" class="input-field-sm w-full">
<option value="semantic">Sentence Transformer (Recommended)</option>
<option value="tfidf">TF-IDF (Fast, Less Accurate)</option>
<option value="openai">OpenAI Ada</option>
<option value="ollama">Ollama Embedding</option>
</select>
</div>
<!-- Execute Remote Code (for custom vectorizers maybe?) -->
<div class="flex items-end pb-1">
<div class="flex items-center space-x-2">
<ToggleSwitch id="rag_vectorizer_execute_remote_code" :checked="config.rag_vectorizer_execute_remote_code" @update:checked="updateBoolean('rag_vectorizer_execute_remote_code', $event)" />
<label for="rag_vectorizer_execute_remote_code" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">Allow Remote Code Execution</label>
<i data-feather="alert-triangle" class="w-4 h-4 text-red-500 ml-1" title="Security Risk: Only enable if using a trusted custom vectorizer source."></i>
</div>
</div>
<!-- RAG Vectorizer Model -->
<div class="md:col-span-2">
<label for="rag_vectorizer_model" class="setting-label-inline">Vectorizer Model</label>
<select
id="rag_vectorizer_model"
:value="config.rag_vectorizer_model"
@change="updateValue('rag_vectorizer_model', $event.target.value)"
class="input-field-sm w-full mb-1"
:disabled="config.rag_vectorizer === 'tfidf'"
>
<!-- Options dynamically loaded or predefined -->
<option v-if="config.rag_vectorizer === 'tfidf'" disabled value="">N/A for TF-IDF</option>
<!-- Semantic Models -->
<optgroup v-if="config.rag_vectorizer === 'semantic'" label="Sentence Transformer Models">
<option value="BAAI/bge-m3">BAAI/bge-m3</option>
<option value="nvidia/NV-Embed-v2">nvidia/NV-Embed-v2</option>
<option value="sentence-transformers/all-MiniLM-L6-v2">all-MiniLM-L6-v2</option>
<option value="sentence-transformers/all-mpnet-base-v2">all-mpnet-base-v2</option>
<!-- Add more or fetch dynamically -->
</optgroup>
<!-- OpenAI Models -->
<optgroup v-if="config.rag_vectorizer === 'openai'" label="OpenAI Models">
<option value="text-embedding-3-large">text-embedding-3-large</option>
<option value="text-embedding-3-small">text-embedding-3-small</option>
<option value="text-embedding-ada-002">text-embedding-ada-002 (Legacy)</option>
</optgroup>
<!-- Ollama Models -->
<optgroup v-if="config.rag_vectorizer === 'ollama'" label="Ollama Embeddings">
<option value="mxbai-embed-large">mxbai-embed-large</option>
<option value="nomic-embed-text">nomic-embed-text</option>
<option value="all-minilm">all-minilm</option>
<option value="snowflake-arctic-embed">snowflake-arctic-embed</option>
<!-- Recommend users pull these via Ollama CLI -->
</optgroup>
<!-- Allow custom entry if needed -->
</select>
<!-- Custom model input (optional, shown if needed) -->
<input
type="text"
:value="config.rag_vectorizer_model"
@input="updateValue('rag_vectorizer_model', $event.target.value)"
class="input-field-sm w-full"
placeholder="Or enter custom model name/path"
:disabled="config.rag_vectorizer === 'tfidf'"
>
</div>
<!-- RAG Service URL (Ollama/OpenAI) -->
<div v-if="config.rag_vectorizer === 'ollama' || config.rag_vectorizer === 'openai'" class="md:col-span-2">
<label for="rag_service_url" class="setting-label-inline">
{{ config.rag_vectorizer === 'ollama' ? 'Ollama Server URL' : 'OpenAI API Base URL' }}
</label>
<input
type="text"
id="rag_service_url"
:value="config.rag_service_url"
@input="updateValue('rag_service_url', $event.target.value)"
class="input-field-sm w-full"
:placeholder="config.rag_vectorizer === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com/v1'"
>
</div>
</div>
<!-- Chunk Size -->
<div class="setting-item">
<label for="rag_chunk_size" class="setting-label">Chunk Size</label>
<div class="flex-1 flex items-center gap-4">
<input id="rag_chunk_size-range" :value="config.rag_chunk_size" @input="updateValue('rag_chunk_size', parseInt($event.target.value))" type="range" min="100" max="2000" step="50" class="range-input">
<input id="rag_chunk_size-number" :value="config.rag_chunk_size" @input="updateValue('rag_chunk_size', parseInt($event.target.value))" type="number" min="100" max="2000" step="50" class="input-field-sm w-24 text-center">
</div>
</div>
<!-- Overlap Size -->
<div class="setting-item">
<label for="rag_overlap_size" class="setting-label">Overlap Size</label>
<div class="flex-1 flex items-center gap-4">
<input id="rag_overlap_size-range" :value="config.rag_overlap_size" @input="updateValue('rag_overlap_size', parseInt($event.target.value))" type="range" min="0" max="500" step="10" class="range-input">
<input id="rag_overlap_size-number" :value="config.rag_overlap_size" @input="updateValue('rag_overlap_size', parseInt($event.target.value))" type="number" min="0" max="500" step="10" class="input-field-sm w-24 text-center">
</div>
</div>
<!-- Clean Chunks Toggle -->
<div class="toggle-item !justify-start gap-4">
<ToggleSwitch id="rag_clean_chunks" :checked="config.rag_clean_chunks" @update:checked="updateBoolean('rag_clean_chunks', $event)" />
<label for="rag_clean_chunks" class="toggle-label !flex-none">
Clean Chunks
<span class="toggle-description">Attempt to remove redundant whitespace and formatting from text chunks before vectorization.</span>
</label>
</div>
</section>
<!-- Data Vectorization Query Settings -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">RAG Query Settings</h3>
<!-- Reformulate Prompt -->
<div class="toggle-item">
<label for="rag_build_keys_words" class="toggle-label">
Reformulate Query with Keywords
<span class="toggle-description">Let the AI extract keywords from your prompt to potentially improve database search relevance.</span>
</label>
<ToggleSwitch id="rag_build_keys_words" :checked="config.rag_build_keys_words" @update:checked="updateBoolean('rag_build_keys_words', $event)" />
</div>
<!-- Put Chunk Info -->
<div class="toggle-item">
<label for="rag_put_chunk_informations_into_context" class="toggle-label">
Include Chunk Source Info in Context
<span class="toggle-description">Prepend retrieved text chunks with source information (e.g., filename) when adding to the LLM context.</span>
</label>
<ToggleSwitch id="rag_put_chunk_informations_into_context" :checked="config.rag_put_chunk_informations_into_context" @update:checked="updateBoolean('rag_put_chunk_informations_into_context', $event)" />
</div>
<!-- Save DB -->
<div class="toggle-item">
<label for="data_vectorization_save_db" class="toggle-label">
Persist Vector Database
<span class="toggle-description">Save the vectorized data to disk. If disabled, the database is in-memory only and lost on restart. (Applies mainly to LoLLMs VectorDB).</span>
</label>
<ToggleSwitch id="data_vectorization_save_db" :checked="config.data_vectorization_save_db" @update:checked="updateBoolean('data_vectorization_save_db', $event)" />
</div>
</section>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, defineProps, defineEmits } from 'vue';
import feather from 'feather-icons';
import axios from 'axios';
import ToggleSwitch from '@/components/ToggleSwitch.vue';
import socket from '@/services/websocket.js';
// Props definition
const props = defineProps({
config: { type: Object, required: true },
loading: { type: Boolean, default: false },
api_post_req: { type: Function, required: true },
api_get_req: { type: Function, required: true },
show_toast: { type: Function, required: true },
client_id: { type: String, required: true }
});
// Emits definition
const emit = defineEmits(['update:setting', 'settings-changed']);
// Reactive state for component-specific UI
const fileInputs = ref([]);
const serverStatuses = reactive({});
// --- Methods ---
const updateValue = (key, value) => {
emit('update:setting', { key, value });
};
const updateBoolean = (key, value) => {
emit('update:setting', { key: key, value: Boolean(value) });
};
// --- Data Lake Methods ---
const updateDataLake = (index, field, value) => {
// Construct the correct key path for nested array update
emit('update:setting', { key: `datalakes[${index}].${field}`, value });
};
const addDataLake = () => {
const currentDatalakes = props.config.datalakes ? [...props.config.datalakes] : [];
currentDatalakes.push({
alias: "New DataLake",
type: "lollmsvectordb",
url: "",
path: "",
key: "",
mounted: false
});
// Emit the entire updated array
emit('update:setting', { key: 'datalakes', value: currentDatalakes });
nextTick(() => feather.replace()); // Ensure icons render for new elements if any
};
const removeDataLake = (index) => {
// Create a new array excluding the item at the index
const currentDatalakes = props.config.datalakes.filter((_, i) => i !== index);
emit('update:setting', { key: 'datalakes', value: currentDatalakes });
};
const vectorizeFolder = async (index) => {
const lake = props.config.datalakes[index];
if (!lake || lake.type !== 'lollmsvectordb' || !lake.path) {
props.show_toast("Please ensure a valid path is set for the LoLLMs VectorDB.", 4, false);
return;
}
props.show_toast(`Starting vectorization for: ${lake.alias}`, 5, true);
try {
await props.api_post_req('vectorize_folder', { rag_database: lake });
// Success feedback might come via websockets or require manual status check
} catch (error) {
props.show_toast(`Vectorization failed for ${lake.alias}: ${error.message}`, 4, false);
}
};
const selectLollmsVectordbFolder = async (index) => {
try {
const listener = (infos) => {
if (infos && infos.path && infos.datalake_name) {
// Use updateDataLake for consistency
updateDataLake(index, 'path', infos.path);
updateDataLake(index, 'alias', infos.datalake_name);
props.show_toast(`Folder selected for Data Lake: ${infos.path}`, 4, true);
} else {
props.show_toast("Folder selection failed or returned invalid data.", 4, false);
}
socket.off("lollmsvectordb_datalake_added", listener);
};
socket.on("lollmsvectordb_datalake_added", listener);
await props.api_post_req('select_lollmsvectordb_input_folder');
} catch (error) {
props.show_toast(`Failed to initiate folder selection: ${error.message}`, 4, false);
socket.off("lollmsvectordb_datalake_added");
}
};
const triggerFileInput = (index) => {
if (fileInputs.value[index]) {
fileInputs.value[index].click();
}
};
const handleFileUpload = async (event, index) => {
const files = Array.from(event.target.files);
const source = props.config.datalakes[index];
if (!files.length || source.type !== 'lightrag') return;
props.show_toast(`Uploading ${files.length} file(s) to ${source.alias}...`, files.length * 2, true);
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('client_id', props.client_id);
const headers = { 'Content-Type': 'multipart/form-data' };
if (source.key) {
headers['X-API-Key'] = source.key;
}
try {
const response = await axios.post(`${source.url.replace(/\/+$/, '')}/documents/upload`, formData, { headers });
if (response.data && (response.status === 200 || response.status === 201)) {
props.show_toast(`${file.name} uploaded successfully to ${source.alias}`, 4, true);
} else {
props.show_toast(`Failed to upload ${file.name}: ${response.data?.detail || 'Server error'}`, 4, false);
}
} catch (error) {
console.error(`Error uploading ${file.name}:`, error);
props.show_toast(`Error uploading ${file.name}: ${error.response?.data?.detail || error.message}`, 4, false);
}
}
event.target.value = null;
};
// --- Database Server Methods ---
const updateServer = (index, field, value) => {
emit('update:setting', { key: `rag_local_services[${index}].${field}`, value });
};
const addDatabaseService = () => {
const currentServers = props.config.rag_local_services ? [...props.config.rag_local_services] : [];
currentServers.push({
alias: "New RAG Server",
type: "lightrag",
url: "http://localhost:9621/",
key: "",
input_path: "",
working_path: "",
start_at_startup: false
});
emit('update:setting', { key: 'rag_local_services', value: currentServers });
nextTick(() => feather.replace());
};
const removeDatabaseService = (index) => {
const currentServers = props.config.rag_local_services.filter((_, i) => i !== index);
emit('update:setting', { key: 'rag_local_services', value: currentServers });
};
const startRagServer = async (index) => {
const server = props.config.rag_local_services[index];
props.show_toast(`Attempting to start server: ${server.alias}...`, 4, true);
try {
const response = await props.api_post_req('start_rag_server', { server_index: index });
if (response.status) {
props.show_toast(`Start command sent for ${server.alias}. Check status shortly.`, 4, true);
setTimeout(() => checkServerHealth(index), 5000);
} else {
props.show_toast(`Failed to send start command for ${server.alias}: ${response.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error starting server ${server.alias}: ${error.message}`, 4, false);
}
};
const checkServerHealth = async (index) => {
const server = props.config.rag_local_services[index];
if (!server || server.type !== 'lightrag') {
serverStatuses[index] = { status: 'unknown', loading: false, details: null, dotClass: 'bg-gray-400', title: 'Unsupported', text: 'Unsupported Type' };
return;
}
serverStatuses[index] = { status: 'loading', loading: true, details: null, dotClass: 'bg-yellow-400 animate-pulse', title: 'Checking...', text: 'Checking...' };
try {
const url = `${server.url.replace(/\/+$/, '')}/health`;
const headers = {};
if (server.key) headers['X-API-Key'] = server.key;
const response = await fetch(url, { headers });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
serverStatuses[index] = { status: 'healthy', loading: false, details: data, dotClass: 'bg-green-500', title: 'Healthy', text: 'Healthy' };
props.show_toast(`${server.alias} is healthy.`, 3, true);
} catch (error) {
console.error(`Health check failed for ${server.alias}:`, error);
serverStatuses[index] = { status: 'unhealthy', loading: false, details: null, dotClass: 'bg-red-500', title: 'Unhealthy', text: 'Unhealthy' };
props.show_toast(`${server.alias} health check failed: ${error.message}`, 4, false);
}
};
const showLightRagWebUI = (index) => {
const server = props.config.rag_local_services[index];
if (server && server.type === 'lightrag' && server.url) {
const webuiUrl = `${server.url.replace(/\/+$/, '')}/webui`;
window.open(webuiUrl, '_blank');
} else {
props.show_toast("Cannot open WebUI. Invalid server configuration.", 4, false);
}
};
const selectLightragFolder = async (index, folderType) => {
const endpoint = folderType === 'input' ? 'select_lightrag_input_folder' : 'select_lightrag_output_folder';
const socketEvent = folderType === 'input' ? 'lightrag_input_folder_added' : 'lightrag_output_folder_added';
const settingKey = folderType === 'input' ? 'input_path' : 'working_path';
try {
const listener = (infos) => {
if (infos && infos.path) {
updateServer(index, settingKey, infos.path);
props.show_toast(`${folderType.charAt(0).toUpperCase() + folderType.slice(1)} folder selected: ${infos.path}`, 4, true);
} else {
props.show_toast(`Folder selection failed for ${folderType}.`, 4, false);
}
socket.off(socketEvent, listener);
};
socket.on(socketEvent, listener);
await props.api_post_req(endpoint, { server_index: index });
} catch (error) {
props.show_toast(`Failed to initiate ${folderType} folder selection: ${error.message}`, 4, false);
socket.off(socketEvent);
}
};
// Lifecycle Hooks
onMounted(() => {
nextTick(() => {
feather.replace();
});
// Initial health check for servers
if (props.config.rag_local_services) {
props.config.rag_local_services.forEach((_, index) => checkServerHealth(index));
}
});
onUpdated(() => {
nextTick(() => {
feather.replace();
});
});
</script>
<!-- Corrected Style Section -->
<style scoped>
.setting-item {
@apply flex flex-col md:flex-row md:items-center gap-2 md:gap-4 py-2;
}
.setting-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 w-full md:w-1/3 lg:w-1/4 flex-shrink-0;
}
.setting-label-inline {
@apply block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1;
}
.input-field {
/* Standard focus */
@apply block w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 sm:text-sm disabled:opacity-50;
}
.input-field-sm {
/* Standard focus */
@apply block w-full px-2.5 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 disabled:opacity-50;
}
.range-input {
/* Standard accent */
@apply w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-600 accent-blue-600 disabled:opacity-50;
}
.toggle-item {
@apply flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors;
}
.toggle-label {
@apply text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1 mr-4;
}
.toggle-description {
@apply block text-xs text-gray-500 dark:text-gray-400 mt-1 font-normal;
}
/* Shared Button Styles (Tailwind) - Standardized */
.button-base {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 transition-colors duration-150;
}
.button-base-sm {
@apply inline-flex items-center justify-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 transition-colors duration-150;
}
/* Use standard blue for primary, unless 'primary' is defined in config */
.button-primary { @apply button-base text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500; }
.button-secondary { @apply button-base text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400; }
.button-success { @apply button-base text-white bg-green-600 hover:bg-green-700 focus:ring-green-500; }
.button-danger { @apply button-base text-white bg-red-600 hover:bg-red-700 focus:ring-red-500; }
.button-primary-sm { @apply button-base-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500; }
.button-secondary-sm { @apply button-base-sm text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400; }
.button-success-sm { @apply button-base-sm text-white bg-green-600 hover:bg-green-700 focus:ring-green-500; }
</style>

View File

@ -0,0 +1,570 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-gray-200 dark:border-gray-700 pb-3 mb-4">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2 sm:mb-0">
Function Calls Zoo
</h2>
<!-- Mounted Functions Display -->
<div class="flex flex-col items-end">
<div class="flex items-center flex-wrap gap-2 text-sm font-medium mb-1">
<span class="text-gray-600 dark:text-gray-400">Mounted:</span>
<div v-if="mountedFunctions.length === 0" class="text-gray-500 dark:text-gray-500 italic text-xs">None</div>
<div v-else class="flex -space-x-3 items-center">
<!-- Limited display of mounted icons -->
<div v-for="(func, index) in displayedMountedFunctions" :key="`mounted-${func.full_path || index}`" class="relative group">
<img :src="getFunctionIcon(func.icon)" @error="imgPlaceholder"
class="w-7 h-7 rounded-full object-cover ring-2 ring-white dark:ring-gray-800 cursor-pointer hover:ring-primary transition-all"
:title="`${func.name} (${func.category})`"
@click="scrollToFunction(func)"> <!-- Click scrolls to the function in the list -->
<button @click.stop="handleUnmount(func)"
class="absolute -top-1 -right-1 p-0.5 rounded-full bg-red-600 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-red-700"
title="Unmount">
<i data-feather="x" class="w-3 h-3"></i>
</button>
</div>
<div v-if="mountedFunctions.length > maxDisplayedMountedFunc"
class="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-600 ring-2 ring-white dark:ring-gray-800 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300"
:title="`${mountedFunctions.length - maxDisplayedMountedFunc} more mounted`">
+{{ mountedFunctions.length - maxDisplayedMountedFunc }}
</div>
</div>
</div>
<button v-if="mountedFunctions.length > 0" @click="unmountAll" class="button-danger-sm text-xs mt-1">
<i data-feather="x-octagon" class="w-3 h-3 mr-1"></i>Unmount All
</button>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Mount functions to grant the AI specific capabilities and tools it can use during conversations. Requires a model trained for function calling.
</p>
<!-- Controls: Search, Category, Sort -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 items-center">
<!-- Search Input -->
<div class="relative md:col-span-1">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-feather="search" class="w-5 h-5 text-gray-400"></i>
</div>
<input
type="search"
v-model="searchTermFunc"
placeholder="Search functions..."
class="input-field pl-10 w-full"
@input="debounceSearchFunc"
/>
<div v-if="isSearchingFunc" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<svg aria-hidden="true" class="w-5 h-5 text-gray-400 animate-spin dark:text-gray-500 fill-primary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
</div>
</div>
<!-- Category Filter -->
<div class="md:col-span-1">
<label for="func-category" class="sr-only">Filter by Category</label>
<select id="func-category" v-model="selectedCategoryFunc" class="input-field">
<option value="">All Categories</option>
<option v-for="cat in categoriesFunc" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<!-- Sort Select -->
<div class="md:col-span-1">
<label for="func-sort" class="sr-only">Sort functions by</label>
<select id="func-sort" v-model="sortOptionFunc" class="input-field">
<option value="name">Sort by Name</option>
<option value="author">Sort by Author</option>
<option value="category">Sort by Category</option>
</select>
</div>
</div>
<!-- Loading / Empty State -->
<div v-if="isLoadingFunctions" class="flex justify-center items-center p-10 text-gray-500 dark:text-gray-400">
<svg aria-hidden="true" class="w-8 h-8 mr-2 text-gray-300 animate-spin dark:text-gray-600 fill-primary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
<span>Loading functions...</span>
</div>
<div v-else-if="pagedFunctions.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-10">
No functions found{{ searchTermFunc ? ' matching "' + searchTermFunc + '"' : '' }}{{ selectedCategoryFunc ? ' in category "' + selectedCategoryFunc + '"' : '' }}.
</div>
<!-- Functions Grid - Lazy Loaded -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" ref="scrollContainerFunc">
<FunctionEntry
v-for="func in pagedFunctions"
:key="func.id || func.full_path"
:ref="el => functionEntryRefs[func.id || func.full_path] = el"
:function_call="func"
:is-mounted="func.isMounted"
@mount="handleMount(func)"
@unmount="handleUnmount(func)"
@remount="handleRemount(func)"
@settings="handleSettings(func)"
@edit="handleEdit(func)"
@copy-to-custom="handleCopyToCustom(func)"
@copy-name="handleCopyName(func)"
@open-folder="handleOpenFolder(func)"
/>
</div>
<!-- Loading More Indicator / Trigger -->
<div ref="loadMoreTriggerFunc" class="h-10">
<div v-if="hasMoreFunctionsToLoad && !isLoadingFunctions" class="text-center text-gray-500 dark:text-gray-400 py-4">
Loading more functions...
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import feather from 'feather-icons';
import FunctionEntry from '@/components/FunctionEntry.vue'; // Assuming this component exists
import defaultFunctionIcon from "@/assets/default_function.png"; // Default icon
const axios = require('axios'); // Use if needed
// Props
const props = defineProps({
config: { type: Object, required: true },
api_post_req: { type: Function, required: true },
api_get_req: { type: Function, required: true },
show_toast: { type: Function, required: true },
show_yes_no_dialog: { type: Function, required: true },
show_universal_form: { type: Function, required: true },
show_message_box: { type: Function, required: true },
client_id: { type: String, required: true }
});
// Emits
const emit = defineEmits(['update:setting']);
// --- State ---
const allFunctions = ref([]); // Master list: [{...functionData, full_path: string, isMounted: boolean, id: string, isProcessing: boolean}]
const categoriesFunc = ref([]);
const filteredFunctions = ref([]);
const pagedFunctions = ref([]);
const mountedFunctions = ref([]); // Derived from config
const isLoadingFunctions = ref(false);
const isSearchingFunc = ref(false);
const searchTermFunc = ref('');
const selectedCategoryFunc = ref('');
const sortOptionFunc = ref('name'); // 'name', 'author', 'category'
const itemsPerPageFunc = ref(15);
const currentPageFunc = ref(1);
const searchDebounceTimerFunc = ref(null);
const scrollContainerFunc = ref(null);
const loadMoreTriggerFunc = ref(null);
const maxDisplayedMountedFunc = ref(7); // Adjust as needed
const functionEntryRefs = reactive({}); // For scrolling to entries
// --- Computed ---
const hasMoreFunctionsToLoad = computed(() => {
return pagedFunctions.value.length < filteredFunctions.value.length;
});
const displayedMountedFunctions = computed(() => {
return mountedFunctions.value.slice(0, maxDisplayedMountedFunc.value);
});
// --- Watchers ---
watch(() => props.config.mounted_functions, (newVal) => {
updateMountedFuncList(newVal);
}, { immediate: true, deep: true });
watch([searchTermFunc, selectedCategoryFunc, sortOptionFunc], () => {
debounceSearchFunc();
});
watch(allFunctions, () => {
currentPageFunc.value = 1;
pagedFunctions.value = [];
applyFiltersAndSortFunc();
loadMoreFunctions();
}, { deep: true });
// --- Methods ---
const getFunctionIcon = (iconPath) => {
if (!iconPath) return defaultFunctionIcon;
// Assuming iconPath is relative
return `${axios.defaults.baseURL}${iconPath.startsWith('/') ? '' : '/'}${iconPath}`;
};
const imgPlaceholder = (event) => {
event.target.src = defaultFunctionIcon;
};
const fetchFunctionsAndCategories = async () => {
isLoadingFunctions.value = true;
console.log("Fetching functions and categories...");
try {
// Fetch all function calls (assuming endpoint returns { function_calls: [...] })
const response = await props.api_get_req("list_function_calls");
const allFuncsRaw = response?.function_calls || [];
// Extract unique categories
const cats = new Set(allFuncsRaw.map(func => func.category));
categoriesFunc.value = Array.from(cats).sort();
const mountedSet = new Set(props.config.mounted_functions || []);
// Process into the desired format
allFunctions.value = allFuncsRaw.map(func => {
const full_path = `${func.category}/${func.name}`; // Assuming 'name' is the unique identifier within category
const uniqueId = func.id || full_path; // Use backend ID if provided
return {
...func,
full_path: full_path,
isMounted: mountedSet.has(full_path),
id: uniqueId,
isProcessing: false
};
});
console.log(`Fetched ${allFunctions.value.length} total functions.`);
updateMountedFuncList(props.config.mounted_functions); // Sync mounted list
} catch (error) {
props.show_toast("Failed to load functions.", 4, false);
console.error("Error fetching functions:", error);
allFunctions.value = [];
categoriesFunc.value = [];
} finally {
isLoadingFunctions.value = false;
nextTick(feather.replace);
}
};
const applyFiltersAndSortFunc = () => {
isSearchingFunc.value = true;
console.time("FilterSortFunctions");
let result = [...allFunctions.value];
// 1. Filter by Category
if (selectedCategoryFunc.value) {
result = result.filter(f => f.category === selectedCategoryFunc.value);
}
// 2. Filter by Search Term (Improved: Clearer field checks)
if (searchTermFunc.value) {
const lowerSearch = searchTermFunc.value.toLowerCase();
result = result.filter(f => {
const nameMatch = f.name?.toLowerCase().includes(lowerSearch);
const authorMatch = f.author?.toLowerCase().includes(lowerSearch);
const descMatch = f.description?.toLowerCase().includes(lowerSearch);
const catMatch = f.category?.toLowerCase().includes(lowerSearch);
const pathMatch = f.full_path?.toLowerCase().includes(lowerSearch);
// Add more fields to search if necessary (e.g., keywords)
const keywordsMatch = Array.isArray(f.keywords) ? f.keywords.some(k => k.toLowerCase().includes(lowerSearch)) : false;
return nameMatch || authorMatch || descMatch || catMatch || pathMatch || keywordsMatch;
});
}
// 3. Sort
result.sort((a, b) => {
// Mounted first
if (a.isMounted && !b.isMounted) return -1;
if (!a.isMounted && b.isMounted) return 1;
// Secondary sort
switch (sortOptionFunc.value) {
case 'name': return (a.name || '').localeCompare(b.name || '');
case 'author': return (a.author || '').localeCompare(b.author || '');
case 'category': return (a.category || '').localeCompare(b.category || '');
default: return 0;
}
});
filteredFunctions.value = result;
console.timeEnd("FilterSortFunctions");
isSearchingFunc.value = false;
console.log(`Filtered/Sorted functions: ${filteredFunctions.value.length}`);
};
const debounceSearchFunc = () => {
isSearchingFunc.value = true;
clearTimeout(searchDebounceTimerFunc.value);
searchDebounceTimerFunc.value = setTimeout(() => {
currentPageFunc.value = 1;
pagedFunctions.value = [];
applyFiltersAndSortFunc();
loadMoreFunctions();
}, 300);
};
const loadMoreFunctions = () => {
if (isLoadingFunctions.value || isSearchingFunc.value) return;
const start = (currentPageFunc.value - 1) * itemsPerPageFunc.value;
const end = start + itemsPerPageFunc.value;
const nextPageItems = filteredFunctions.value.slice(start, end);
pagedFunctions.value.push(...nextPageItems);
currentPageFunc.value++;
nextTick(feather.replace);
};
const updateMountedFuncList = (mountedPathsArray) => {
const mountedSet = new Set(mountedPathsArray || []);
mountedFunctions.value = allFunctions.value.filter(f => mountedSet.has(f.full_path));
// Update isMounted status on the main list
allFunctions.value.forEach(f => {
f.isMounted = mountedSet.has(f.full_path);
});
// applyFiltersAndSortFunc(); // Re-sort/filter if needed
console.log("Updated mounted function list:", mountedFunctions.value.length);
};
const setFunctionProcessing = (funcId, state) => {
const index = allFunctions.value.findIndex(f => (f.id || f.full_path) === funcId);
if (index !== -1) {
allFunctions.value[index].isProcessing = state;
const pagedIndex = pagedFunctions.value.findIndex(f => (f.id || f.full_path) === funcId);
if (pagedIndex !== -1) pagedFunctions.value[pagedIndex].isProcessing = state;
}
};
// --- Function Actions ---
const handleMount = async (func) => {
if (func.isMounted) return;
const funcId = func.id || func.full_path;
setFunctionProcessing(funcId, true);
props.show_toast(`Mounting ${func.name}...`, 3, true);
try {
const response = await props.api_post_req('mount_function_call', {
function_category: func.category,
function_name: func.name // Assuming 'name' is the identifier within category
});
if (response && response.status) {
props.show_toast(`${func.name} mounted successfully.`, 4, true);
// Update config
const newMountedList = [...(props.config.mounted_functions || []), func.full_path];
emit('update:setting', { key: 'mounted_functions', value: newMountedList });
// Update local state (watcher will also catch config change)
const index = allFunctions.value.findIndex(f => (f.id || f.full_path) === funcId);
if (index !== -1) {
allFunctions.value[index].isMounted = true;
allFunctions.value = [...allFunctions.value];
}
} else {
props.show_toast(`Failed to mount ${func.name}: ${response?.error || 'Error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error mounting ${func.name}: ${error.message}`, 4, false);
} finally {
setFunctionProcessing(funcId, false);
}
};
const handleUnmount = async (func) => {
if (!func.isMounted) return;
const funcId = func.id || func.full_path;
setFunctionProcessing(funcId, true);
props.show_toast(`Unmounting ${func.name}...`, 3, true);
try {
const response = await props.api_post_req('unmount_function_call', {
function_name: func.name // Assuming backend identifies by name
});
if (response && response.status) {
props.show_toast(`${func.name} unmounted.`, 4, true);
// Update config
const newMountedList = (props.config.mounted_functions || []).filter(p => p !== func.full_path);
emit('update:setting', { key: 'mounted_functions', value: newMountedList });
// Update local state
const index = allFunctions.value.findIndex(f => (f.id || f.full_path) === funcId);
if (index !== -1) {
allFunctions.value[index].isMounted = false;
allFunctions.value = [...allFunctions.value];
}
} else {
props.show_toast(`Failed to unmount ${func.name}: ${response?.error || 'Error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error unmounting ${func.name}: ${error.message}`, 4, false);
} finally {
setFunctionProcessing(funcId, false);
}
};
const unmountAll = async () => {
const yes = await props.show_yes_no_dialog(`Unmount all functions?`, 'Unmount All', 'Cancel');
if (!yes) return;
props.show_toast(`Unmounting all functions...`, 3, true);
try {
const response = await props.api_post_req('unmount_all_functions');
if (response && response.status) {
props.show_toast('All functions unmounted.', 4, true);
emit('update:setting', { key: 'mounted_functions', value: [] });
// Update local state
allFunctions.value.forEach(f => f.isMounted = false);
allFunctions.value = [...allFunctions.value];
} else {
props.show_toast(`Failed to unmount all: ${response?.error || 'Error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error unmounting all: ${error.message}`, 4, false);
}
};
const handleRemount = async (func) => {
const funcId = func.id || func.full_path;
setFunctionProcessing(funcId, true);
props.show_toast(`Remounting ${func.name}...`, 3, true);
try {
if (func.isMounted) await handleUnmount(func); // Unmount first if mounted
await handleMount(func); // Then mount
} catch (e) { /* Errors handled in sub-functions */ }
finally { setFunctionProcessing(funcId, false); }
};
const handleSettings = async (func) => {
const funcId = func.id || func.full_path;
setFunctionProcessing(funcId, true);
try {
const settingsData = await props.api_post_req('get_function_call_settings', {
category: func.category,
name: func.name
});
if (settingsData && Object.keys(settingsData).length > 0) {
const result = await props.show_universal_form(settingsData, `Function Settings - ${func.name}`, "Save", "Cancel");
const setResponse = await props.api_post_req('set_function_call_settings', {
category: func.category,
name: func.name,
settings: result
});
if (setResponse && setResponse.status) {
props.show_toast(`Settings for ${func.name} updated.`, 4, true);
} else {
props.show_toast(`Failed to update settings for ${func.name}: ${setResponse?.error || 'Error'}`, 4, false);
}
} else {
props.show_toast(`Function "${func.name}" has no configurable settings.`, 4, false);
}
} catch (error) {
props.show_toast(`Error accessing settings for ${func.name}: ${error.message}`, 4, false);
} finally {
setFunctionProcessing(funcId, false);
}
};
const handleEdit = async (func) => {
props.show_toast(`Editing ${func.name} requires opening its folder.`, 3, true);
await handleOpenFolder(func);
};
const handleCopyToCustom = async (func) => {
const yes = await props.show_yes_no_dialog(`Copy "${func.name}" to your 'custom_functions' folder?`, 'Copy', 'Cancel');
if (!yes) return;
const funcId = func.id || func.full_path;
setFunctionProcessing(funcId, true);
try {
const response = await props.api_post_req('copy_to_custom_functions', { // Endpoint might differ
category: func.category,
name: func.name
});
if (response && response.status) {
props.show_message_box(`Function "${func.name}" copied to 'custom_functions'.`);
await fetchFunctionsAndCategories(); // Refresh list
} else {
props.show_toast(`Failed to copy ${func.name}: ${response?.error || 'Already exists?'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error copying ${func.name}: ${error.message}`, 4, false);
} finally {
setFunctionProcessing(funcId, false);
}
};
const handleCopyName = (func) => {
navigator.clipboard.writeText(func.name)
.then(() => props.show_toast(`Copied name: ${func.name}`, 3, true))
.catch(() => props.show_toast("Failed to copy name.", 3, false));
};
const handleOpenFolder = async (func) => {
try {
// Assuming backend uses category/name
await props.api_post_req("open_function_folder", { category: func.category, name: func.name });
} catch (error) {
props.show_toast(`Error opening folder for ${func.name}: ${error.message}`, 4, false);
}
};
const scrollToFunction = (func) => {
const funcId = func.id || func.full_path;
const elementRef = functionEntryRefs[funcId];
if (elementRef && elementRef.$el) {
elementRef.$el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Optional: Add a temporary highlight effect
elementRef.$el.classList.add('ring-2', 'ring-primary', 'ring-offset-2', 'dark:ring-offset-gray-800', 'transition-all', 'duration-1000');
setTimeout(() => {
elementRef.$el.classList.remove('ring-2', 'ring-primary', 'ring-offset-2', 'dark:ring-offset-gray-800', 'transition-all', 'duration-1000');
}, 1500);
} else {
console.warn(`Could not find ref to scroll to for function ID: ${funcId}`);
// Potentially load more pages until the function is found? Could be complex.
}
};
// --- Infinite Scroll ---
let observerFunc = null;
const setupIntersectionObserverFunc = () => {
const options = { root: null, rootMargin: '0px', threshold: 0.1 };
observerFunc = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMoreFunctionsToLoad.value && !isLoadingFunctions.value && !isSearchingFunc.value) {
loadMoreFunctions();
}
});
}, options);
if (loadMoreTriggerFunc.value) observerFunc.observe(loadMoreTriggerFunc.value);
};
// --- Lifecycle ---
onMounted(() => {
fetchFunctionsAndCategories();
nextTick(() => {
feather.replace();
if (loadMoreTriggerFunc.value) setupIntersectionObserverFunc();
});
});
onUnmounted(() => {
if (observerFunc && loadMoreTriggerFunc.value) observerFunc.unobserve(loadMoreTriggerFunc.value);
if (observerFunc) observerFunc.disconnect();
clearTimeout(searchDebounceTimerFunc.value);
});
onUpdated(() => {
nextTick(() => {
feather.replace();
if (!observerFunc && loadMoreTriggerFunc.value) setupIntersectionObserverFunc();
else if (observerFunc && loadMoreTriggerFunc.value) {
observerFunc.disconnect();
observerFunc.observe(loadMoreTriggerFunc.value);
}
});
});
</script>
<style scoped>
/* Shared styles */
.input-field {
@apply block w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary disabled:opacity-50;
}
.button-base-sm {
@apply inline-flex items-center justify-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 transition-colors duration-150;
}
.button-danger-sm { @apply button-base-sm text-white bg-red-600 hover:bg-red-700 focus:ring-red-500; }
/* Add transition group styles if needed */
</style>

View File

@ -0,0 +1,173 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 pb-2">
Internet Search
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Configure how LoLLMs interacts with the internet to answer questions or find information. Requires a model capable of function calling or specific instruction following.
</p>
<!-- Internet Search Toggles -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Activation & Behavior</h3>
<!-- Activate Internet Search -->
<div class="toggle-item">
<label for="activate_internet_search" class="toggle-label">
Enable Automatic Internet Search
<span class="toggle-description">Allow the AI to decide when to search the internet based on the prompt.</span>
</label>
<ToggleSwitch id="activate_internet_search" :checked="config.activate_internet_search" @update:checked="updateBoolean('activate_internet_search', $event)" />
</div>
<!-- Activate Search Decision -->
<div class="toggle-item" :class="{ 'opacity-50 pointer-events-none': !config.activate_internet_search }">
<label for="internet_activate_search_decision" class="toggle-label">
Enable Explicit Search Decision
<span class="toggle-description">Make the AI explicitly state whether it needs to search the internet before performing the search.</span>
</label>
<ToggleSwitch id="internet_activate_search_decision" :checked="config.internet_activate_search_decision" @update:checked="updateBoolean('internet_activate_search_decision', $event)" :disabled="!config.activate_internet_search"/>
</div>
<!-- Activate Pages Judgement -->
<div class="toggle-item" :class="{ 'opacity-50 pointer-events-none': !config.activate_internet_search }">
<label for="activate_internet_pages_judgement" class="toggle-label">
Enable Search Result Evaluation
<span class="toggle-description">Allow the AI to evaluate the relevance and quality of search result snippets before using them.</span>
</label>
<ToggleSwitch id="activate_internet_pages_judgement" :checked="config.activate_internet_pages_judgement" @update:checked="updateBoolean('activate_internet_pages_judgement', $event)" :disabled="!config.activate_internet_search"/>
</div>
<!-- Activate Quick Search -->
<div class="toggle-item" :class="{ 'opacity-50 pointer-events-none': !config.activate_internet_search }">
<label for="internet_quick_search" class="toggle-label">
Enable Quick Search
<span class="toggle-description">Perform a faster search potentially using fewer results or less processing, might be less accurate.</span>
</label>
<ToggleSwitch id="internet_quick_search" :checked="config.internet_quick_search" @update:checked="updateBoolean('internet_quick_search', $event)" :disabled="!config.activate_internet_search"/>
</div>
</section>
<!-- Internet Search Parameters -->
<section :class="['space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg', !config.activate_internet_search ? 'opacity-50 pointer-events-none' : '']">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Search Parameters</h3>
<!-- Number of Search Pages/Results -->
<div class="setting-item">
<label for="internet_nb_search_pages" class="setting-label">
Number of Search Results
<span class="block text-xs font-normal text-gray-500 dark:text-gray-400 mt-1">Controls how many search result snippets are initially retrieved.</span>
</label>
<div class="flex-1 flex items-center gap-4">
<input id="internet_nb_search_pages-range" :value="config.internet_nb_search_pages" @input="updateValue('internet_nb_search_pages', parseInt($event.target.value))" type="range" min="1" max="20" step="1" class="range-input" :disabled="!config.activate_internet_search">
<input id="internet_nb_search_pages-number" :value="config.internet_nb_search_pages" @input="updateValue('internet_nb_search_pages', parseInt($event.target.value))" type="number" min="1" max="20" step="1" class="input-field-sm w-20 text-center" :disabled="!config.activate_internet_search">
</div>
</div>
<!-- Vectorization Chunk Size -->
<div class="setting-item">
<label for="internet_vectorization_chunk_size" class="setting-label">
Content Chunk Size
<span class="block text-xs font-normal text-gray-500 dark:text-gray-400 mt-1">Size of text chunks when processing content from searched web pages (if applicable).</span>
</label>
<div class="flex-1 flex items-center gap-4">
<input id="internet_vectorization_chunk_size-range" :value="config.internet_vectorization_chunk_size" @input="updateValue('internet_vectorization_chunk_size', parseInt($event.target.value))" type="range" min="100" max="1000" step="50" class="range-input" :disabled="!config.activate_internet_search">
<input id="internet_vectorization_chunk_size-number" :value="config.internet_vectorization_chunk_size" @input="updateValue('internet_vectorization_chunk_size', parseInt($event.target.value))" type="number" min="100" max="1000" step="50" class="input-field-sm w-20 text-center" :disabled="!config.activate_internet_search">
</div>
</div>
<!-- Vectorization Overlap Size -->
<div class="setting-item">
<label for="internet_vectorization_overlap_size" class="setting-label">
Content Overlap Size
<span class="block text-xs font-normal text-gray-500 dark:text-gray-400 mt-1">Overlap between text chunks when processing web page content.</span>
</label>
<div class="flex-1 flex items-center gap-4">
<input id="internet_vectorization_overlap_size-range" :value="config.internet_vectorization_overlap_size" @input="updateValue('internet_vectorization_overlap_size', parseInt($event.target.value))" type="range" min="0" max="200" step="10" class="range-input" :disabled="!config.activate_internet_search">
<input id="internet_vectorization_overlap_size-number" :value="config.internet_vectorization_overlap_size" @input="updateValue('internet_vectorization_overlap_size', parseInt($event.target.value))" type="number" min="0" max="200" step="10" class="input-field-sm w-20 text-center" :disabled="!config.activate_internet_search">
</div>
</div>
<!-- Number of Vectorization Chunks to Use -->
<div class="setting-item">
<label for="internet_vectorization_nb_chunks" class="setting-label">
Number of Content Chunks to Use
<span class="block text-xs font-normal text-gray-500 dark:text-gray-400 mt-1">Maximum number of processed text chunks from web pages to include in the context.</span>
</label>
<div class="flex-1 flex items-center gap-4">
<input id="internet_vectorization_nb_chunks-range" :value="config.internet_vectorization_nb_chunks" @input="updateValue('internet_vectorization_nb_chunks', parseInt($event.target.value))" type="range" min="1" max="20" step="1" class="range-input" :disabled="!config.activate_internet_search">
<input id="internet_vectorization_nb_chunks-number" :value="config.internet_vectorization_nb_chunks" @input="updateValue('internet_vectorization_nb_chunks', parseInt($event.target.value))" type="number" min="1" max="20" step="1" class="input-field-sm w-20 text-center" :disabled="!config.activate_internet_search">
</div>
</div>
</section>
</div>
</template>
<script setup>
import { defineProps, defineEmits, onMounted, nextTick } from 'vue';
import feather from 'feather-icons';
import ToggleSwitch from '@/components/ToggleSwitch.vue';
// Props definition
const props = defineProps({
config: { type: Object, required: true },
loading: { type: Boolean, default: false }
});
// Emits definition
const emit = defineEmits(['update:setting']);
// --- Methods ---
const updateValue = (key, value) => {
// Ensure numeric values from range/number inputs are parsed correctly
const numericKeys = [
'internet_nb_search_pages',
'internet_vectorization_chunk_size',
'internet_vectorization_overlap_size',
'internet_vectorization_nb_chunks'
];
const finalValue = numericKeys.includes(key) ? parseInt(value) || 0 : value; // Default to 0 if parse fails
emit('update:setting', { key, value: finalValue });
};
const updateBoolean = (key, value) => {
emit('update:setting', { key, value: Boolean(value) });
};
// Lifecycle Hooks
onMounted(() => {
nextTick(() => {
feather.replace();
});
});
</script>
<style scoped>
/* Using shared styles defined in previous components or globally */
.setting-item {
@apply flex flex-col md:flex-row md:items-center gap-2 md:gap-4 py-2;
}
.setting-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 w-full md:w-1/3 lg:w-1/4 flex-shrink-0;
}
.input-field-sm {
@apply block w-full px-2.5 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary disabled:opacity-50;
}
.range-input {
@apply w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-600 accent-primary disabled:opacity-50;
}
.toggle-item {
@apply flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors;
}
.toggle-label {
@apply text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1 mr-4;
}
.toggle-description {
@apply block text-xs text-gray-500 dark:text-gray-400 mt-1 font-normal;
}
</style>

View File

@ -0,0 +1,285 @@
// src/components/settings_components/MainConfigSettings.vue
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 pb-2">
Main Configuration
</h2>
<!-- Application Branding Section -->
<div class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Application Branding</h3>
<!-- App Name -->
<div class="setting-item">
<label for="app_custom_name" class="setting-label">Application Name</label>
<input
type="text"
id="app_custom_name"
:value="config.app_custom_name"
@input="updateValue('app_custom_name', $event.target.value)"
class="input-field"
placeholder="Default: LoLLMs"
>
</div>
<!-- App Slogan -->
<div class="setting-item">
<label for="app_custom_slogan" class="setting-label">Application Slogan</label>
<input
type="text"
id="app_custom_slogan"
:value="config.app_custom_slogan"
@input="updateValue('app_custom_slogan', $event.target.value)"
class="input-field"
placeholder="Default: Lord of Large Language Models"
>
</div>
<!-- App Logo -->
<div class="setting-item items-start">
<label class="setting-label pt-2">Application Logo</label>
<div class="flex-1 flex items-center gap-4">
<div class="w-12 h-12 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 ring-2 ring-offset-2 dark:ring-offset-gray-800 ring-gray-300 dark:ring-gray-600">
<img :src="logoSrc" class="w-full h-full object-cover" alt="App Logo">
</div>
<div class="flex gap-2">
<label class="button-primary text-sm cursor-pointer">
Upload Logo
<input type="file" @change="uploadLogo" accept="image/*" class="hidden">
</label>
<button
v-if="config.app_custom_logo"
@click="removeLogo"
class="button-danger text-sm">
Remove Logo
</button>
</div>
</div>
</div>
<!-- Welcome Message -->
<div class="setting-item items-start">
<label for="app_custom_welcome_message" class="setting-label pt-2">Custom Welcome Message</label>
<textarea
id="app_custom_welcome_message"
:value="config.app_custom_welcome_message"
@input="updateValue('app_custom_welcome_message', $event.target.value)"
class="input-field min-h-[80px] resize-y"
placeholder="Enter a custom welcome message shown on the main page (leave blank for default)."
></textarea>
</div>
</div>
<!-- UI Behavior Section -->
<div class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">UI & Behavior</h3>
<!-- Auto Title -->
<div class="toggle-item">
<label for="auto_title" class="toggle-label">
Automatic Discussion Naming
<span class="toggle-description">Let AI name your discussions automatically based on the first message.</span>
</label>
<ToggleSwitch id="auto_title" :checked="config.auto_title" @update:checked="updateBoolean('auto_title', $event)" />
</div>
<!-- Show Browser -->
<div class="toggle-item">
<label for="auto_show_browser" class="toggle-label">
Auto-launch Browser
<span class="toggle-description">Open the default web browser automatically when LoLLMs starts.</span>
</label>
<ToggleSwitch id="auto_show_browser" :checked="config.auto_show_browser" @update:checked="updateBoolean('auto_show_browser', $event)" />
</div>
<!-- Show Change Log -->
<div class="toggle-item">
<label for="app_show_changelogs" class="toggle-label">
Show Startup Changelog
<span class="toggle-description">Display the changelog modal window when the application starts after an update.</span>
</label>
<ToggleSwitch id="app_show_changelogs" :checked="config.app_show_changelogs" @update:checked="updateBoolean('app_show_changelogs', $event)" />
</div>
<!-- Show Fun Facts -->
<div class="toggle-item">
<label for="app_show_fun_facts" class="toggle-label">
Show Fun Facts
<span class="toggle-description">Display fun facts related to AI and LLMs while loading or waiting.</span>
</label>
<ToggleSwitch id="app_show_fun_facts" :checked="config.app_show_fun_facts" @update:checked="updateBoolean('app_show_fun_facts', $event)" />
</div>
<!-- Enhanced Copy -->
<div class="toggle-item">
<label for="copy_to_clipboard_add_all_details" class="toggle-label">
Enhanced Message Copy
<span class="toggle-description">Include metadata (sender, model, etc.) when copying messages from discussions.</span>
</label>
<ToggleSwitch id="copy_to_clipboard_add_all_details" :checked="config.copy_to_clipboard_add_all_details" @update:checked="updateBoolean('copy_to_clipboard_add_all_details', $event)" />
</div>
</div>
<!-- Server & Access Section -->
<div class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Server & Access</h3>
<!-- Remote Access Warning & Toggle -->
<div class="setting-item items-start p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-700">
<div class="flex justify-between items-start w-full">
<label for="force_accept_remote_access" class="flex-1 mr-4">
<span class="font-bold text-sm text-red-700 dark:text-red-400 flex items-center gap-2">
<i data-feather="alert-triangle" class="w-5 h-5"></i> Enable Remote Access (Security Risk)
</span>
<p class="mt-2 text-xs text-red-600 dark:text-red-400/90">
<strong>Warning:</strong> Enabling this allows connections from any device on your network (or potentially the internet if port-forwarded).
<strong class="block mt-1">Only enable if you understand the risks and have secured your network.</strong>
</p>
</label>
<ToggleSwitch id="force_accept_remote_access" :checked="config.force_accept_remote_access" @update:checked="updateBoolean('force_accept_remote_access', $event)" />
</div>
</div>
<!-- Headless Mode -->
<div class="toggle-item">
<label for="headless_server_mode" class="toggle-label">
Headless Server Mode
<span class="toggle-description">Run LoLLMs without the Web UI. Useful for server deployments or API-only usage. This setting requires a restart.</span>
</label>
<ToggleSwitch id="headless_server_mode" :checked="config.headless_server_mode" @update:checked="updateBoolean('headless_server_mode', $event)" />
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed, ref, onMounted, nextTick } from 'vue';
import axios from 'axios';
import feather from 'feather-icons';
import ToggleSwitch from '@/components/ToggleSwitch.vue';
import defaultLogoPlaceholder from "@/assets/logo.png"; // Your default logo
// Props definition
const props = defineProps({
config: { type: Object, required: true },
loading: { type: Boolean, default: false },
settingsChanged: { type: Boolean, default: false },
api_post_req: { type: Function, required: true }, // Assuming parent provides this for upload/remove
show_toast: { type: Function, required: true },
client_id: { type: String, required: true }
});
// Emits definition
const emit = defineEmits(['update:setting', 'settings-changed']);
const isUploadingLogo = ref(false);
// --- Computed ---
const logoSrc = computed(() => {
if (props.config.app_custom_logo) {
return `${axios.defaults.baseURL}/user_infos/${props.config.app_custom_logo}`;
}
return defaultLogoPlaceholder;
});
// --- Methods ---
const updateValue = (key, value) => {
emit('update:setting', { key, value });
};
const updateBoolean = (key, value) => {
emit('update:setting', { key: key, value: Boolean(value) });
};
const uploadLogo = async (event) => {
const file = event.target.files[0];
if (!file) return;
isUploadingLogo.value = true;
const formData = new FormData();
formData.append('logo', file);
formData.append('client_id', props.client_id);
try {
const response = await axios.post('/upload_logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data && response.data.status) {
props.show_toast("Logo uploaded successfully!", 4, true);
emit('update:setting', { key: 'app_custom_logo', value: response.data.filename });
} else {
props.show_toast(`Logo upload failed: ${response.data.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
console.error('Error uploading logo:', error);
props.show_toast(`Error uploading logo: ${error.message}`, 4, false);
} finally {
isUploadingLogo.value = false;
event.target.value = null;
}
};
const removeLogo = async () => {
try {
const response = await props.api_post_req('/remove_logo');
if (response.status) {
props.show_toast("Logo removed successfully!", 4, true);
emit('update:setting', { key: 'app_custom_logo', value: null });
} else {
props.show_toast(`Failed to remove logo: ${response.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
console.error('Error removing logo:', error);
props.show_toast(`Error removing logo: ${error.message}`, 4, false);
}
};
// Lifecycle Hooks
onMounted(() => {
nextTick(() => {
feather.replace();
});
});
onUpdated(() => {
nextTick(() => {
feather.replace();
});
});
</script>
<style scoped>
.setting-item {
@apply flex flex-col md:flex-row md:items-center gap-2 md:gap-4 py-2;
}
.setting-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 w-full md:w-1/3 lg:w-1/4 flex-shrink-0;
}
.input-field {
@apply block w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 sm:text-sm disabled:opacity-50;
}
.toggle-item {
@apply flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors;
}
.toggle-label {
@apply text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1 mr-4;
}
.toggle-description {
@apply block text-xs text-gray-500 dark:text-gray-400 mt-1 font-normal;
}
/* Updated Button Styles */
.button-primary {
/* Assuming 'primary' is defined as blue-600 in config or default */
@apply px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md shadow-sm transition duration-150 ease-in-out cursor-pointer disabled:opacity-50;
}
.button-danger {
@apply px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md shadow-sm transition duration-150 ease-in-out cursor-pointer disabled:opacity-50;
}
</style>

View File

@ -0,0 +1,217 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 pb-2">
Model Generation Parameters
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Adjust the core parameters that influence how the AI generates text. These settings can be overridden by specific personalities unless the option below is checked.
</p>
<!-- Override Personality Toggle -->
<div class="toggle-item !justify-start gap-4 border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<ToggleSwitch
id="override_personality_model_parameters"
:checked="config.override_personality_model_parameters"
@update:checked="updateBoolean('override_personality_model_parameters', $event)"
/>
<label for="override_personality_model_parameters" class="toggle-label !flex-none">
Override Personality Parameters
<span class="toggle-description">Force the use of these global parameters, ignoring any settings defined within the selected personality.</span>
</label>
</div>
<!-- Parameter Controls -->
<div :class="['space-y-5 pt-4', isDisabled ? 'opacity-50 pointer-events-none' : '']">
<!-- Seed -->
<div class="setting-item">
<label for="seed" class="setting-label flex items-center">
Seed
<i data-feather="info" class="w-4 h-4 ml-1 text-gray-400 cursor-help" title="Controls randomness. Set to -1 for random, or a specific number for reproducible results."></i>
</label>
<input
type="number"
id="seed"
:value="config.seed"
@input="updateValue('seed', parseInt($event.target.value))"
class="input-field-sm w-full md:w-32"
step="1"
placeholder="-1"
:disabled="isDisabled"
>
</div>
<!-- Temperature -->
<div class="setting-item items-start md:items-center">
<label for="temperature-range" class="setting-label flex items-center">
Temperature
<i data-feather="info" class="w-4 h-4 ml-1 text-gray-400 cursor-help" title="Controls randomness. Lower values (e.g., 0.2) make output more focused and deterministic, higher values (e.g., 0.8) make it more creative and random."></i>
</label>
<div class="flex-1 flex flex-col sm:flex-row items-center gap-4 w-full">
<input id="temperature-range" :value="config.temperature" @input="updateValue('temperature', parseFloat($event.target.value))" type="range" min="0" max="2" step="0.01" class="range-input flex-grow" :disabled="isDisabled">
<input id="temperature-number" :value="config.temperature" @input="updateValue('temperature', parseFloat($event.target.value))" type="number" min="0" max="2" step="0.01" class="input-field-sm w-24 text-center" :disabled="isDisabled">
</div>
</div>
<!-- N Predict (Max Tokens) -->
<div class="setting-item items-start md:items-center">
<label for="n_predict-range" class="setting-label flex items-center">
Max New Tokens
<i data-feather="info" class="w-4 h-4 ml-1 text-gray-400 cursor-help" title="Maximum number of tokens the model is allowed to generate in a single response."></i>
</label>
<div class="flex-1 flex flex-col sm:flex-row items-center gap-4 w-full">
<input id="n_predict-range" :value="config.n_predict" @input="updateValue('n_predict', parseInt($event.target.value))" type="range" min="32" max="8192" step="32" class="range-input flex-grow" :disabled="isDisabled">
<input id="n_predict-number" :value="config.n_predict" @input="updateValue('n_predict', parseInt($event.target.value))" type="number" min="32" max="8192" step="32" class="input-field-sm w-24 text-center" :disabled="isDisabled">
</div>
</div>
<!-- Top-K -->
<div class="setting-item items-start md:items-center">
<label for="top_k-range" class="setting-label flex items-center">
Top-K Sampling
<i data-feather="info" class="w-4 h-4 ml-1 text-gray-400 cursor-help" title="Limits generation to the K most likely next tokens. Reduces repetition but can make output less creative. 0 disables it."></i>
</label>
<div class="flex-1 flex flex-col sm:flex-row items-center gap-4 w-full">
<input id="top_k-range" :value="config.top_k" @input="updateValue('top_k', parseInt($event.target.value))" type="range" min="0" max="100" step="1" class="range-input flex-grow" :disabled="isDisabled">
<input id="top_k-number" :value="config.top_k" @input="updateValue('top_k', parseInt($event.target.value))" type="number" min="0" max="100" step="1" class="input-field-sm w-24 text-center" :disabled="isDisabled">
</div>
</div>
<!-- Top-P -->
<div class="setting-item items-start md:items-center">
<label for="top_p-range" class="setting-label flex items-center">
Top-P (Nucleus) Sampling
<i data-feather="info" class="w-4 h-4 ml-1 text-gray-400 cursor-help" title="Considers only the most probable tokens whose cumulative probability exceeds P. Allows for dynamic vocabulary size. 1.0 disables it. Common values: 0.9, 0.95."></i>
</label>
<div class="flex-1 flex flex-col sm:flex-row items-center gap-4 w-full">
<input id="top_p-range" :value="config.top_p" @input="updateValue('top_p', parseFloat($event.target.value))" type="range" min="0" max="1" step="0.01" class="range-input flex-grow" :disabled="isDisabled">
<input id="top_p-number" :value="config.top_p" @input="updateValue('top_p', parseFloat($event.target.value))" type="number" min="0" max="1" step="0.01" class="input-field-sm w-24 text-center" :disabled="isDisabled">
</div>
</div>
<!-- Repeat Penalty -->
<div class="setting-item items-start md:items-center">
<label for="repeat_penalty-range" class="setting-label flex items-center">
Repeat Penalty
<i data-feather="info" class="w-4 h-4 ml-1 text-gray-400 cursor-help" title="Penalizes tokens that have appeared recently. Higher values (e.g., 1.1, 1.2) reduce repetition. 1.0 disables it."></i>
</label>
<div class="flex-1 flex flex-col sm:flex-row items-center gap-4 w-full">
<input id="repeat_penalty-range" :value="config.repeat_penalty" @input="updateValue('repeat_penalty', parseFloat($event.target.value))" type="range" min="0.5" max="2.0" step="0.01" class="range-input flex-grow" :disabled="isDisabled">
<input id="repeat_penalty-number" :value="config.repeat_penalty" @input="updateValue('repeat_penalty', parseFloat($event.target.value))" type="number" min="0.5" max="2.0" step="0.01" class="input-field-sm w-24 text-center" :disabled="isDisabled">
</div>
</div>
<!-- Repeat Last N -->
<div class="setting-item items-start md:items-center">
<label for="repeat_last_n-range" class="setting-label flex items-center">
Repeat Penalty Lookback
<i data-feather="info" class="w-4 h-4 ml-1 text-gray-400 cursor-help" title="Number of recent tokens to consider when applying the repeat penalty. 0 disables considering previous tokens specifically for penalty."></i>
</label>
<div class="flex-1 flex flex-col sm:flex-row items-center gap-4 w-full">
<input id="repeat_last_n-range" :value="config.repeat_last_n" @input="updateValue('repeat_last_n', parseInt($event.target.value))" type="range" min="0" max="512" step="8" class="range-input flex-grow" :disabled="isDisabled">
<input id="repeat_last_n-number" :value="config.repeat_last_n" @input="updateValue('repeat_last_n', parseInt($event.target.value))" type="number" min="0" max="512" step="8" class="input-field-sm w-24 text-center" :disabled="isDisabled">
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed, onMounted, nextTick } from 'vue';
import feather from 'feather-icons';
import ToggleSwitch from '@/components/ToggleSwitch.vue';
// Props definition
const props = defineProps({
config: { type: Object, required: true },
loading: { type: Boolean, default: false }
});
// Emits definition
const emit = defineEmits(['update:setting']);
// Computed property to check if parameters should be disabled
const isDisabled = computed(() => {
return !props.config.override_personality_model_parameters;
});
// --- Methods ---
const updateValue = (key, value) => {
// Ensure correct type (Number for ranges/numbers, leave Seed as potentially string/number)
let finalValue = value;
if (key !== 'seed' && typeof value !== 'boolean') {
finalValue = Number(value);
if (isNaN(finalValue)) {
console.warn(`Invalid number input for ${key}:`, value);
// Optionally revert or set to a default if NaN
// For now, we let it pass, backend might handle validation
finalValue = value; // Keep original if parse fails, might be intermediate typing
}
} else if (key === 'seed') {
// Allow -1 or positive integers for seed
finalValue = parseInt(value);
if (isNaN(finalValue) && value !== '-') { // Allow typing '-' for negative
finalValue = -1; // Default to random if invalid input
} else if (value === '-') {
finalValue = '-'; // Allow '-' intermediate state
} else if (finalValue < -1) {
finalValue = -1; // Enforce minimum of -1
}
}
// Avoid emitting NaN during intermediate number input states
if (key !== 'seed' && typeof finalValue === 'number' && isNaN(finalValue)) {
return;
}
if (key === 'seed' && finalValue === '-') {
return; // Don't emit just the hyphen
}
emit('update:setting', { key, value: finalValue });
};
const updateBoolean = (key, value) => {
emit('update:setting', { key, value: Boolean(value) });
};
// Lifecycle Hooks
onMounted(() => {
nextTick(() => {
feather.replace();
});
});
onUpdated(() => {
nextTick(() => {
feather.replace();
});
});
</script>
<style scoped>
/* Using shared styles */
.setting-item {
@apply flex flex-col md:flex-row md:items-center gap-2 md:gap-4 py-2;
}
.setting-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 w-full md:w-1/3 lg:w-1/4 flex-shrink-0;
}
.input-field-sm {
@apply block w-full px-2.5 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary disabled:opacity-50;
}
.range-input {
@apply w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-600 accent-primary disabled:opacity-50;
}
.toggle-item {
@apply flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors;
}
.toggle-label {
@apply text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1 mr-4;
}
.toggle-description {
@apply block text-xs text-gray-500 dark:text-gray-400 mt-1 font-normal;
}
</style>

View File

@ -0,0 +1,818 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-gray-200 dark:border-gray-700 pb-3 mb-4">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2 sm:mb-0">
Models Zoo
</h2>
<!-- Current Model Display -->
<div v-if="currentModelInfo" class="flex items-center gap-2 text-sm font-medium p-2 bg-primary-light dark:bg-primary-dark/20 rounded-md border border-primary-dark/30 shrink-0">
<img :src="currentModelInfo.icon" @error="imgPlaceholder" class="w-6 h-6 rounded-lg object-cover flex-shrink-0" alt="Current Model Icon">
<span>Active: <span class="font-semibold">{{ currentModelInfo.name }}</span></span>
<!-- Add settings/info button if applicable to models -->
</div>
<div v-else-if="!config.binding_name" class="text-sm font-medium text-orange-600 dark:text-orange-400 p-2 bg-orange-50 dark:bg-orange-900/20 rounded-md border border-orange-300 dark:border-orange-600 shrink-0">
Select a Binding first!
</div>
<div v-else class="text-sm font-medium text-red-600 dark:text-red-400 p-2 bg-red-50 dark:bg-red-900/20 rounded-md border border-red-300 dark:border-red-600 shrink-0">
No model selected!
</div>
</div>
<!-- Info and Warnings -->
<p class="text-sm text-gray-500 dark:text-gray-400">
Select a model compatible with your chosen binding (<span class="font-semibold">{{ currentBindingName || 'None Selected' }}</span>). Installed models are shown first.
</p>
<div v-if="!config.binding_name" class="p-3 text-center text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/30 rounded-md border border-orange-200 dark:border-orange-700">
Please select a Binding from the 'Bindings' section to see available models.
</div>
<!-- Controls: Search, Filters, Sort -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 items-center">
<!-- Search Input -->
<div class="relative md:col-span-2">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-feather="search" class="w-5 h-5 text-gray-400"></i>
</div>
<input
type="search"
v-model="searchTerm"
placeholder="Search models by name, author, quantizer, description..."
class="input-field pl-10 w-full"
@input="debounceSearch"
/>
<div v-if="isSearching" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<svg aria-hidden="true" class="w-5 h-5 text-gray-400 animate-spin dark:text-gray-500 fill-primary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
</div>
</div>
<!-- Filters -->
<div class="flex items-center space-x-2">
<label for="model-filter-installed" class="flex items-center space-x-1 cursor-pointer text-sm">
<input type="checkbox" id="model-filter-installed" v-model="showInstalledOnly" class="rounded text-primary focus:ring-primary-dark border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-offset-gray-800">
<span>Installed</span>
</label>
<!-- Add more filters if needed (e.g., by type, size) -->
</div>
<!-- Sort Select -->
<div>
<label for="model-sort" class="sr-only">Sort models by</label>
<select id="model-sort" v-model="sortOption" class="input-field">
<option value="rank">Sort by Rank</option>
<option value="name">Sort by Name</option>
<option value="last_commit_time">Sort by Date</option>
<option value="quantizer">Sort by Quantizer</option>
<option value="license">Sort by License</option>
</select>
</div>
</div>
<!-- Loading / Empty State -->
<div v-if="isLoadingModels" class="flex justify-center items-center p-10 text-gray-500 dark:text-gray-400">
<svg aria-hidden="true" class="w-8 h-8 mr-2 text-gray-300 animate-spin dark:text-gray-600 fill-primary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
<span>Loading models...</span>
</div>
<div v-else-if="pagedModels.length === 0 && models.length > 0" class="text-center text-gray-500 dark:text-gray-400 py-10">
No models found matching filters{{ searchTerm ? ' and search "' + searchTerm + '"' : '' }}.
</div>
<div v-else-if="models.length === 0 && !isLoadingModels && config.binding_name" class="text-center text-gray-500 dark:text-gray-400 py-10">
No models available for the selected binding. Try adding a reference below.
</div>
<!-- Models Grid - Lazy Loaded -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" ref="scrollContainer">
<ModelEntry
v-for="model in pagedModels"
:key="model.id || model.name"
:model="model"
:is-selected="config.model_name === model.name"
@select="handleSelect(model)"
@install="handleInstall"
@uninstall="handleUninstall"
@cancel-install="handleCancelInstall"
@copy="handleCopy"
@copy-link="handleCopyLink"
/>
</div>
<!-- Loading More Indicator / Trigger -->
<div ref="loadMoreTrigger" class="h-10">
<div v-if="hasMoreModelsToLoad && !isLoadingModels" class="text-center text-gray-500 dark:text-gray-400 py-4">
Loading more models...
</div>
</div>
<!-- Add Model / Reference Section -->
<section class="pt-6 border-t border-gray-200 dark:border-gray-700 mt-6">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Add Model</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Add Reference Path -->
<div>
<label for="reference_path" class="setting-label-inline">Add Reference to Local Model File/Folder</label>
<div class="flex">
<input type="text" id="reference_path" v-model="referencePath" class="input-field-sm rounded-r-none flex-grow" placeholder="Enter full path to model file or folder...">
<button @click="createReference" class="button-primary-sm rounded-l-none flex-shrink-0" title="Add Reference">Add</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Creates a link without copying the model. Requires binding support.</p>
</div>
<!-- Download from URL / Hugging Face -->
<div>
<label for="model_url" class="setting-label-inline">Download Model from URL or Hugging Face ID</label>
<div class="flex">
<input type="text" id="model_url" v-model="modelUrl" class="input-field-sm rounded-r-none flex-grow" placeholder="Enter URL or HF ID (e.g., TheBloke/Llama-2-7B-GGUF)...">
<button @click="installFromInput" class="button-success-sm rounded-l-none flex-shrink-0" title="Download and Install" :disabled="isDownloading">
<i :data-feather="isDownloading ? 'loader' : 'download'" :class="['w-4 h-4', isDownloading ? 'animate-spin' : '']"></i>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Downloads the model to the binding's models folder.</p>
</div>
</div>
<!-- Download Progress (Conditional) -->
<div v-if="downloadProgress.visible" class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-md">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">Downloading: {{ downloadProgress.name }}</span>
<span class="text-xs font-medium text-blue-600 dark:text-blue-400">{{ downloadProgress.progress.toFixed(1) }}%</span>
</div>
<div class="w-full bg-blue-200 rounded-full h-1.5 dark:bg-blue-700">
<div class="bg-blue-600 h-1.5 rounded-full" :style="{ width: downloadProgress.progress + '%' }"></div>
</div>
<div class="flex justify-between items-center mt-1 text-xs text-blue-600 dark:text-blue-400">
<span>{{ downloadedSizeComputed }} / {{ totalSizeComputed }}</span>
<span>{{ speedComputed }}/s</span>
</div>
<button @click="handleCancelInstall(downloadProgress.details)" class="button-danger-sm mt-2 text-xs">Cancel Download</button>
</div>
</section>
<!-- Variant Selection Dialog Placeholder -->
<ChoiceDialog
:show="variantSelectionDialog.visible"
:title="variantSelectionDialog.title"
:choices="variantSelectionDialog.choices"
@choice-selected="handleVariantSelected"
@choice-validated="handleVariantValidated"
@close-dialog="closeVariantDialog"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import feather from 'feather-icons';
import filesize from '@/plugins/filesize';
import ModelEntry from '@/components/ModelEntry.vue';
import ChoiceDialog from '@/components/ChoiceDialog.vue'; // Assuming component exists
import socket from '@/services/websocket.js';
import defaultModelIcon from "@/assets/default_model.png"; // Default icon
// Props
const props = defineProps({
config: { type: Object, required: true },
api_post_req: { type: Function, required: true },
api_get_req: { type: Function, required: true },
show_toast: { type: Function, required: true },
show_yes_no_dialog: { type: Function, required: true },
client_id: { type: String, required: true }
});
// Emits
const emit = defineEmits(['update:setting']);
// --- State ---
const allModels = ref([]); // Holds the full list fetched from backend
const filteredModels = ref([]); // Holds models after search/filter/sort
const pagedModels = ref([]); // Holds the currently rendered models (for pagination/lazy load)
const isLoadingModels = ref(false);
const isSearching = ref(false); // Indicate background search/filter is happening
const searchTerm = ref('');
const sortOption = ref('rank'); // Default sort: rank
const showInstalledOnly = ref(false);
const referencePath = ref('');
const modelUrl = ref(''); // For the download input field
const isDownloading = ref(false); // Tracks if *any* download is active
const itemsPerPage = ref(15); // How many models to load/show at a time
const currentPage = ref(1);
const searchDebounceTimer = ref(null);
const scrollContainer = ref(null); // Ref for the grid container
const loadMoreTrigger = ref(null); // Ref for the element triggering more loads
// Download Progress State
const downloadProgress = reactive({
visible: false,
name: '',
progress: 0,
speed: 0,
total_size: 0,
downloaded_size: 0,
details: null // Store full model object or identifier
});
// Variant Selection Dialog State
const variantSelectionDialog = reactive({
visible: false,
title: "Select Model Variant",
choices: [],
modelToInstall: null,
selectedVariant: null
});
// --- Computed ---
const currentBindingName = computed(() => {
// Requires access to bindings list, potentially pass from parent or fetch here if needed
// Placeholder logic:
return props.config?.binding_name || 'None Selected';
});
const currentModelInfo = computed(() => {
if (!props.config || !props.config.model_name || allModels.value.length === 0) {
return null;
}
const current = allModels.value.find(m => m.name === props.config.model_name);
// Fallback to finding in currently paged models if full list is huge and not fully processed?
// const current = pagedModels.value.find(m => m.name === props.config.model_name) || allModels.value.find(m => m.name === props.config.model_name);
return current ? { name: current.name, icon: current.icon || defaultModelIcon } : null;
});
const hasMoreModelsToLoad = computed(() => {
return pagedModels.value.length < filteredModels.value.length;
});
// Computed properties for download progress display
const speedComputed = computed(() => filesize(downloadProgress.speed || 0));
const totalSizeComputed = computed(() => filesize(downloadProgress.total_size || 0));
const downloadedSizeComputed = computed(() => filesize(downloadProgress.downloaded_size || 0));
// --- Watchers ---
watch([searchTerm, sortOption, showInstalledOnly, () => props.config.binding_name], () => {
// Reset pagination and re-apply filters when controls change or binding changes
currentPage.value = 1;
pagedModels.value = []; // Clear current page
applyFiltersAndSort(); // This will update filteredModels
loadMoreModels(); // Load the first page of the new filtered list
});
// Watch the master list changing (e.g., after fetching)
watch(allModels, () => {
currentPage.value = 1;
pagedModels.value = [];
applyFiltersAndSort();
loadMoreModels();
}, { deep: true }); // Use deep watch if model properties like 'installed' can change
// --- Methods ---
const fetchModels = async () => {
if (!props.config.binding_name) {
allModels.value = [];
console.log("No binding selected, clearing models.");
return;
}
isLoadingModels.value = true;
console.log(`Fetching models for binding: ${props.config.binding_name}`);
try {
// 1. Get Zoo models (potentially large list)
const zooModels = await props.api_get_req(`list_models?binding=${props.config.binding_name}`);
// 2. Get Installed models (usually a smaller list)
const installedModels = await props.api_get_req(`get_installed_models?binding=${props.config.binding_name}`);
const installedSet = new Set(installedModels.map(m => m.name)); // Efficient lookup
// 3. Combine and Mark Installed Status
const combinedModels = (zooModels || []).map(model => ({
...model,
isInstalled: installedSet.has(model.name),
isProcessing: false, // For install/uninstall spinners
// Add a unique ID if the backend provides one, otherwise rely on name/path
id: model.id || `${model.name}-${model.quantizer || ''}`
}));
// 4. Add any installed models that weren't in the zoo list (custom references)
installedModels.forEach(installedModel => {
if (!combinedModels.some(m => m.name === installedModel.name)) {
combinedModels.push({
...installedModel, // Use data from get_installed_models
name: installedModel.name,
isInstalled: true,
isProcessing: false,
isCustomModel: true, // Flag it as potentially custom
icon: installedModel.icon || defaultModelIcon, // Use provided icon or default
id: installedModel.id || installedModel.name // Unique ID
});
}
});
allModels.value = combinedModels;
console.log(`Fetched ${allModels.value.length} total models.`);
} catch (error) {
props.show_toast("Failed to load models.", 4, false);
console.error("Error fetching models:", error);
allModels.value = [];
} finally {
isLoadingModels.value = false;
nextTick(feather.replace);
}
};
const applyFiltersAndSort = () => {
isSearching.value = true; // Indicate processing started
console.time("FilterSortModels"); // Start timing
let result = [...allModels.value];
// 1. Filter by "Installed Only"
if (showInstalledOnly.value) {
result = result.filter(m => m.isInstalled);
}
// 2. Filter by Search Term (case-insensitive, check multiple fields)
if (searchTerm.value) {
const lowerSearch = searchTerm.value.toLowerCase();
result = result.filter(m =>
m.name?.toLowerCase().includes(lowerSearch) ||
m.author?.toLowerCase().includes(lowerSearch) || // If author exists
m.quantizer?.toLowerCase().includes(lowerSearch) || // If quantizer exists
m.description?.toLowerCase().includes(lowerSearch) || // If description exists
m.license?.toLowerCase().includes(lowerSearch) // If license exists
);
}
// 3. Sort
result.sort((a, b) => {
// Always put installed models first regardless of other sort options
if (a.isInstalled && !b.isInstalled) return -1;
if (!a.isInstalled && b.isInstalled) return 1;
// Then apply the selected sort option
switch (sortOption.value) {
case 'rank':
return (b.rank ?? -Infinity) - (a.rank ?? -Infinity); // Higher rank first (handle missing rank)
case 'name':
return (a.name || '').localeCompare(b.name || '');
case 'last_commit_time': {
// Handle potential null or invalid dates gracefully
const dateA = a.last_commit_time ? new Date(a.last_commit_time) : null;
const dateB = b.last_commit_time ? new Date(b.last_commit_time) : null;
if (dateA && dateB) return dateB - dateA; // Descending date (newest first)
if (dateA) return -1; // Put valid dates first
if (dateB) return 1;
return 0;
}
case 'quantizer':
return (a.quantizer || '').localeCompare(b.quantizer || '');
case 'license':
return (a.license || '').localeCompare(b.license || '');
default:
return 0;
}
});
filteredModels.value = result;
console.timeEnd("FilterSortModels"); // End timing
isSearching.value = false; // Indicate processing finished
console.log(`Filtered/Sorted models: ${filteredModels.value.length}`);
};
const debounceSearch = () => {
isSearching.value = true; // Show spinner immediately on input
clearTimeout(searchDebounceTimer.value);
searchDebounceTimer.value = setTimeout(() => {
// Trigger the actual filter/sort which will set isSearching back to false
currentPage.value = 1;
pagedModels.value = [];
applyFiltersAndSort();
loadMoreModels();
}, 500); // Adjust debounce delay (ms) as needed
};
const loadMoreModels = () => {
if (isLoadingModels.value || isSearching.value) return; // Prevent loading during initial fetch or search debounce
console.log(`Loading page ${currentPage.value}`);
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
const nextPageItems = filteredModels.value.slice(start, end);
pagedModels.value.push(...nextPageItems);
currentPage.value++;
nextTick(feather.replace); // Ensure icons render for new items
};
// --- Model Actions ---
const handleSelect = (model) => {
if (!model.isInstalled) {
props.show_toast(`Model "${model.name}" is not installed.`, 3, false);
return;
}
if (props.config.model_name !== model.name) {
// Set loading state specific to model selection if needed
props.show_toast(`Selecting model: ${model.name}...`, 2, true);
emit('update:setting', { key: 'model_name', value: model.name });
// The parent watcher on config.model_name should handle backend updates/checks
}
};
const handleInstall = (modelEntryData) => {
const model = modelEntryData.model; // Extract the core model data
console.log("Initiating install for:", model);
// Check if variants exist
if (model.variants && model.variants.length > 0) {
variantSelectionDialog.choices = model.variants.map(v => ({
...v, // Spread variant properties like name, size, etc.
id: v.name, // Use variant name as unique ID for ChoiceDialog
label: `${v.name} (${filesize(v.size || 0)})` // Example label
}));
variantSelectionDialog.modelToInstall = model; // Store the main model info
variantSelectionDialog.visible = true;
} else {
// No variants, proceed with direct install using the main model URL/path if available
const path = model.path || `https://huggingface.co/${model.quantizer || 'Unknown'}/${model.name}/resolve/main/${model.filename || model.name}`; // Construct path if needed
startDownload(model, path, model.filename || model.name);
}
};
const handleVariantSelected = (choice) => {
variantSelectionDialog.selectedVariant = choice;
};
const handleVariantValidated = (choice) => {
if (!choice || !variantSelectionDialog.modelToInstall) {
console.error("No variant selected or model info missing.");
closeVariantDialog();
return;
}
const model = variantSelectionDialog.modelToInstall;
const variant = choice; // The validated choice object from ChoiceDialog
const path = variant.path || `https://huggingface.co/${model.quantizer || 'Unknown'}/${model.name}/resolve/main/${variant.name}`; // Construct path
startDownload(model, path, variant.name); // Pass variant name
closeVariantDialog();
};
const closeVariantDialog = () => {
variantSelectionDialog.visible = false;
variantSelectionDialog.choices = [];
variantSelectionDialog.modelToInstall = null;
variantSelectionDialog.selectedVariant = null;
};
const startDownload = (model, path, variantName) => {
console.log(`Starting download for: ${model.name}, Variant: ${variantName}, Path: ${path}`);
if (isDownloading.value) {
props.show_toast("Another download is already in progress.", 3, false);
return;
}
setModelProcessing(model.id || model.name, true); // Use model ID or name as key
isDownloading.value = true;
downloadProgress.visible = true;
downloadProgress.name = `${model.name}${variantName !== model.name ? ` (${variantName})` : ''}`;
downloadProgress.progress = 0;
downloadProgress.speed = 0;
downloadProgress.total_size = 0;
downloadProgress.downloaded_size = 0;
downloadProgress.details = { // Store identifiers needed for cancellation
model_name: model.name,
binding_folder: props.config.binding_name, // Assuming current binding
model_url: path,
variant_name: variantName,
model_id: model.id || model.name // Use the same key as setModelProcessing
// patreon: model.patreon?model.patreon:"None" // If needed by backend cancel
};
// Emit install request via socket
socket.emit('install_model', {
path: path,
name: model.name,
variant_name: variantName,
type: model.type || 'gguf', // Provide type if available
binding: props.config.binding_name // Send current binding
});
};
const handleUninstall = async (modelEntryData) => {
const model = modelEntryData.model;
const yes = await props.show_yes_no_dialog(`Are you sure you want to uninstall model "${model.name}"?`, 'Uninstall', 'Cancel');
if (!yes) return;
setModelProcessing(model.id || model.name, true);
isDownloading.value = true; // Use this to prevent other actions
downloadProgress.visible = true; // Show a generic "uninstalling" message
downloadProgress.name = `Uninstalling ${model.name}...`;
downloadProgress.progress = 50; // Indicate activity
downloadProgress.details = { model_id: model.id || model.name }; // Store identifier
try {
// Backend needs to know which file(s) to delete based on model name/variant
// This might require listing installed files first or passing enough info
const response = await props.api_post_req('uninstall_model', {
name: model.name,
// variant: model.installed_variant // If backend tracks this
binding: props.config.binding_name // Send current binding
});
if (response && response.status) {
props.show_toast(`Model "${model.name}" uninstalled successfully.`, 4, true);
// Update local state immediately
const index = allModels.value.findIndex(m => (m.id || m.name) === (model.id || model.name));
if (index !== -1) {
allModels.value[index].isInstalled = false;
// Trigger reactivity for computed properties
allModels.value = [...allModels.value];
}
// No need to call fetchModels() if we update locally
} else {
props.show_toast(`Failed to uninstall model "${model.name}": ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error uninstalling model "${model.name}": ${error.message}`, 4, false);
console.error(`Error uninstalling ${model.name}:`, error);
} finally {
setModelProcessing(model.id || model.name, false);
downloadProgress.visible = false;
isDownloading.value = false;
}
};
const handleCancelInstall = (downloadDetails) => {
if (!downloadDetails) return;
console.log('Cancelling install for:', downloadDetails);
socket.emit('cancel_install', {
model_name: downloadDetails.model_name,
binding_folder: downloadDetails.binding_folder,
model_url: downloadDetails.model_url,
variant_name: downloadDetails.variant_name
// patreon: downloadDetails.patreon // If needed
});
// State reset is handled by the 'install_progress' listener receiving a cancel/fail status
};
const handleCopy = (modelEntryData) => {
const model = modelEntryData.model;
let content = `Model: ${model.name}\n`;
if (model.quantizer) content += `Quantizer: ${model.quantizer}\n`;
if (model.rank) content += `Rank: ${model.rank}\n`;
if (model.license) content += `License: ${model.license}\n`;
if (model.description) content += `Description: ${model.description}\n`;
if (!model.isCustomModel) content += `Link: https://huggingface.co/${model.quantizer || 'Unknown'}/${model.name}\n`;
navigator.clipboard.writeText(content.trim())
.then(() => props.show_toast("Model info copied!", 3, true))
.catch(err => props.show_toast("Failed to copy info.", 3, false));
};
const handleCopyLink = (modelEntryData) => {
const model = modelEntryData.model;
const link = model.isCustomModel ? model.name : `https://huggingface.co/${model.quantizer || 'Unknown'}/${model.name}`;
navigator.clipboard.writeText(link)
.then(() => props.show_toast("Link copied!", 3, true))
.catch(err => props.show_toast("Failed to copy link.", 3, false));
};
const createReference = async () => {
if (!referencePath.value) {
props.show_toast("Please enter a path for the local model reference.", 3, false);
return;
}
isLoadingModels.value = true; // Indicate activity
try {
const response = await props.api_post_req("add_reference_to_local_model", { path: referencePath.value });
if (response.status) {
props.show_toast("Reference created successfully.", 4, true);
referencePath.value = ''; // Clear input
await fetchModels(); // Refresh the model list
} else {
props.show_toast(`Couldn't create reference: ${response.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error creating reference: ${error.message}`, 4, false);
} finally {
isLoadingModels.value = false;
}
};
const installFromInput = () => {
if (!modelUrl.value) {
props.show_toast("Please enter a Model URL or Hugging Face ID.", 3, false);
return;
}
// Basic check if it looks like a HF ID (e.g., contains '/')
let path = modelUrl.value;
let modelNameGuess = modelUrl.value;
let modelQuantizerGuess = 'Unknown';
if (modelUrl.value.includes('/') && !modelUrl.value.startsWith('http')) {
const parts = modelUrl.value.split('/');
if (parts.length >= 2) {
modelQuantizerGuess = parts[0];
modelNameGuess = parts[1];
// Attempt to construct a likely path, backend might need more robust handling
path = `https://huggingface.co/${modelQuantizerGuess}/${modelNameGuess}`;
}
} else if (!modelUrl.value.startsWith('http')) {
props.show_toast("Invalid Hugging Face ID format. Use 'User/Model'.", 4, false);
return;
}
// Create a placeholder model object for the download process
const placeholderModel = {
name: modelNameGuess,
quantizer: modelQuantizerGuess,
type: 'gguf', // Assume GGUF or let backend determine
id: modelNameGuess // Use name as temporary ID
};
// Currently doesn't support variant selection for direct URL/ID input
startDownload(placeholderModel, path, modelNameGuess);
modelUrl.value = ''; // Clear input after starting
};
const imgPlaceholder = (event) => {
event.target.src = defaultModelIcon;
};
const setModelProcessing = (modelId, state) => {
const index = allModels.value.findIndex(m => (m.id || m.name) === modelId);
if (index !== -1) {
allModels.value[index].isProcessing = state;
// Also update pagedModels if the item exists there for immediate UI feedback
const pagedIndex = pagedModels.value.findIndex(m => (m.id || m.name) === modelId);
if (pagedIndex !== -1) {
pagedModels.value[pagedIndex].isProcessing = state;
}
} else {
console.warn("Couldn't find model to set processing state for ID:", modelId);
}
};
// --- Socket Listeners ---
const installProgressListener = (response) => {
console.log("Socket install_progress:", response);
const modelId = response.model_id || response.model_name; // Use ID if available
if (response.status === 'progress' || response.status === 'downloading') {
downloadProgress.visible = true;
downloadProgress.name = `${response.model_name}${response.variant_name !== response.model_name ? ` (${response.variant_name})` : ''}`;
downloadProgress.progress = response.progress || 0;
downloadProgress.speed = response.speed || 0;
downloadProgress.total_size = response.total_size || 0;
downloadProgress.downloaded_size = response.downloaded_size || 0;
// Ensure the details object is populated if it wasn't already
if (!downloadProgress.details || downloadProgress.details.model_id !== modelId) {
downloadProgress.details = {
model_name: response.model_name,
binding_folder: response.binding_folder,
model_url: response.model_url,
variant_name: response.variant_name,
model_id: modelId
};
}
// Update processing state on the actual model entry
setModelProcessing(modelId, true);
} else if (response.status === 'succeeded') {
props.show_toast(`Model "${response.model_name}" installed successfully!`, 4, true);
downloadProgress.visible = false;
isDownloading.value = false;
setModelProcessing(modelId, false);
// Update installed status in the main list
const index = allModels.value.findIndex(m => (m.id || m.name) === modelId);
if (index !== -1) {
allModels.value[index].isInstalled = true;
allModels.value = [...allModels.value]; // Trigger reactivity
}
} else if (response.status === 'failed' || response.status === 'cancelled') {
props.show_toast(`Model "${response.model_name}" installation ${response.status}: ${response.error || ''}`, 4, false);
downloadProgress.visible = false;
isDownloading.value = false;
setModelProcessing(modelId, false);
}
};
// --- Infinite Scroll ---
let observer = null;
const setupIntersectionObserver = () => {
const options = {
root: null, // Use the viewport
rootMargin: '0px',
threshold: 0.1 // Trigger when 10% of the trigger element is visible
};
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMoreModelsToLoad.value && !isLoadingModels.value && !isSearching.value) {
console.log("Intersection observer triggered: Loading more models.");
loadMoreModels();
}
});
}, options);
if (loadMoreTrigger.value) {
observer.observe(loadMoreTrigger.value);
} else {
console.warn("Load more trigger element not found for IntersectionObserver.");
}
};
// --- Lifecycle Hooks ---
onMounted(() => {
fetchModels(); // Initial fetch
socket.on('install_progress', installProgressListener);
nextTick(() => {
feather.replace();
if (loadMoreTrigger.value) {
setupIntersectionObserver();
}
});
});
onUnmounted(() => {
socket.off('install_progress', installProgressListener);
if (observer && loadMoreTrigger.value) {
observer.unobserve(loadMoreTrigger.value);
}
if (observer) {
observer.disconnect();
}
clearTimeout(searchDebounceTimer.value);
});
onUpdated(() => {
nextTick(() => {
feather.replace();
// Ensure observer is attached if the trigger element becomes available after an update
if (!observer && loadMoreTrigger.value) {
setupIntersectionObserver();
} else if (observer && loadMoreTrigger.value) {
// Re-observe in case the element was replaced
observer.disconnect();
observer.observe(loadMoreTrigger.value);
}
});
});
</script>
<style scoped>
/* Shared styles */
.input-field {
/* Standard focus, background, border */
@apply block w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 disabled:opacity-50;
}
.input-field-sm {
/* Standard focus, background, border - smaller version */
@apply block w-full px-2.5 py-1.5 text-xs bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 disabled:opacity-50;
}
/* Shared Button Styles (Tailwind) - Standardized */
.button-base-sm {
@apply inline-flex items-center justify-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 transition-colors duration-150;
}
/* Use standard blue for primary, green for success etc. */
.button-primary-sm { @apply button-base-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500; }
.button-success-sm { @apply button-base-sm text-white bg-green-600 hover:bg-green-700 focus:ring-green-500; }
.button-danger-sm { @apply button-base-sm text-white bg-red-600 hover:bg-red-700 focus:ring-red-500; }
/* Specific styles */
/* Add transition group styles if needed */
.model-grid-enter-active,
.model-grid-leave-active {
transition: all 0.5s ease;
}
.model-grid-enter-from,
.model-grid-leave-to {
opacity: 0;
transform: translateY(15px);
}
.model-grid-leave-active {
/* Consider if position absolute is needed for your specific transition */
/* position: absolute; */
}
/* Style for the currently active model display */
.bg-primary-light { /* Using a lighter blue for background */
@apply bg-blue-100;
}
.dark .bg-primary-dark\/20 { /* Using blue with opacity in dark mode */
@apply bg-blue-500/20;
}
.border-primary-dark\/30 { /* Using blue with opacity for border */
@apply border-blue-500/30;
}
.dark .hover\:bg-primary-dark\/20:hover { /* Hover effect */
@apply hover:bg-blue-500/30;
}
.hover\:bg-primary-dark\/20:hover { /* Hover effect */
@apply hover:bg-blue-200;
}
</style>

View File

@ -0,0 +1,710 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-gray-200 dark:border-gray-700 pb-3 mb-4">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2 sm:mb-0">
Personalities Zoo
</h2>
<!-- Mounted Personalities Display -->
<div class="flex flex-col items-end">
<div class="flex items-center flex-wrap gap-2 text-sm font-medium mb-1">
<span class="text-gray-600 dark:text-gray-400">Mounted:</span>
<div v-if="mountedPersonalities.length === 0" class="text-gray-500 dark:text-gray-500 italic text-xs">None</div>
<div v-else class="flex -space-x-3 items-center">
<!-- Limited display of mounted icons -->
<div v-for="(pers, index) in displayedMountedPersonalities" :key="`mounted-${pers.full_path || index}`" class="relative group">
<img :src="getPersonalityIcon(pers.avatar)" @error="imgPlaceholder"
class="w-7 h-7 rounded-full object-cover ring-2 ring-white dark:ring-gray-800 cursor-pointer hover:ring-primary transition-all"
:class="{ 'ring-primary dark:ring-primary': isActivePersonality(pers) }"
:title="`${pers.name} (${pers.category}) ${isActivePersonality(pers) ? '- Active' : ''}`"
@click="handleSelect(pers)">
<button @click.stop="handleUnmount(pers)"
class="absolute -top-1 -right-1 p-0.5 rounded-full bg-red-600 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-red-700"
title="Unmount">
<i data-feather="x" class="w-3 h-3"></i>
</button>
</div>
<div v-if="mountedPersonalities.length > maxDisplayedMounted"
class="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-600 ring-2 ring-white dark:ring-gray-800 flex items-center justify-center text-xs font-semibold text-gray-600 dark:text-gray-300"
:title="`${mountedPersonalities.length - maxDisplayedMounted} more mounted`">
+{{ mountedPersonalities.length - maxDisplayedMounted }}
</div>
</div>
</div>
<button v-if="mountedPersonalities.length > 0" @click="unmountAll" class="button-danger-sm text-xs mt-1">
<i data-feather="x-octagon" class="w-3 h-3 mr-1"></i>Unmount All
</button>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Mount personalities to make them available for selection in discussions. The active personality determines the AI's behavior and persona.
</p>
<!-- Controls: Search, Category, Sort -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4 items-center">
<!-- Search Input -->
<div class="relative md:col-span-1">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-feather="search" class="w-5 h-5 text-gray-400"></i>
</div>
<input
type="search"
v-model="searchTerm"
placeholder="Search personalities..."
class="input-field pl-10 w-full"
@input="debounceSearch"
/>
<div v-if="isSearching" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<svg aria-hidden="true" class="w-5 h-5 text-gray-400 animate-spin dark:text-gray-500 fill-primary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
</div>
</div>
<!-- Category Filter -->
<div class="md:col-span-1">
<label for="pers-category" class="sr-only">Filter by Category</label>
<select id="pers-category" v-model="selectedCategory" class="input-field">
<option value="">All Categories</option>
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<!-- Sort Select -->
<div class="md:col-span-1">
<label for="pers-sort" class="sr-only">Sort personalities by</label>
<select id="pers-sort" v-model="sortOption" class="input-field">
<option value="name">Sort by Name</option>
<option value="author">Sort by Author</option>
<option value="category">Sort by Category</option>
<!-- Add more options: popularity, date added? -->
</select>
</div>
</div>
<!-- Loading / Empty State -->
<div v-if="isLoadingPersonalities" class="flex justify-center items-center p-10 text-gray-500 dark:text-gray-400">
<svg aria-hidden="true" class="w-8 h-8 mr-2 text-gray-300 animate-spin dark:text-gray-600 fill-primary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
<span>Loading personalities...</span>
</div>
<div v-else-if="pagedPersonalities.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-10">
No personalities found{{ searchTerm ? ' matching "' + searchTerm + '"' : '' }}{{ selectedCategory ? ' in category "' + selectedCategory + '"' : '' }}.
</div>
<!-- Personalities Grid - Lazy Loaded -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" ref="scrollContainerPers">
<PersonalityEntry
v-for="pers in pagedPersonalities"
:key="pers.id || pers.full_path"
:personality="pers"
:is-mounted="pers.isMounted"
:is-active="isActivePersonality(pers)"
:select_language="true"
@select="handleSelect(pers)"
@mount="handleMount(pers)"
@unmount="handleUnmount(pers)"
@remount="handleRemount(pers)"
@edit="handleEdit(pers)"
@copy-to-custom="handleCopyToCustom(pers)"
@reinstall="handleReinstall(pers)"
@settings="handleSettings(pers)"
@copy-personality-name="handleCopyName(pers)"
@open-folder="handleOpenFolder(pers)"
/>
</div>
<!-- Loading More Indicator / Trigger -->
<div ref="loadMoreTriggerPers" class="h-10">
<div v-if="hasMorePersonalitiesToLoad && !isLoadingPersonalities" class="text-center text-gray-500 dark:text-gray-400 py-4">
Loading more personalities...
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import feather from 'feather-icons';
import PersonalityEntry from '@/components/PersonalityEntry.vue'; // Assuming this component exists
import defaultPersonalityIcon from "@/assets/logo.png"; // Default icon for personalities
const axios = require('axios'); // If needed directly, though api_post_req is preferred
// Props
const props = defineProps({
config: { type: Object, required: true },
api_post_req: { type: Function, required: true },
api_get_req: { type: Function, required: true },
show_toast: { type: Function, required: true },
show_yes_no_dialog: { type: Function, required: true },
show_message_box: { type: Function, required: true }, // Added for copy to custom message
client_id: { type: String, required: true },
refresh_config: { type: Function, required: true }, // Function to trigger parent config refresh
});
// Emits
const emit = defineEmits(['update:setting']);
// --- State ---
const allPersonalities = ref([]); // Holds the full list [{...personalityData, full_path: string, isMounted: boolean, id: string, isProcessing: boolean}]
const categories = ref([]); // List of unique category names
const filteredPersonalities = ref([]);
const pagedPersonalities = ref([]);
const mountedPersonalities = ref([]); // Derived from config for display
const isLoadingPersonalities = ref(false);
const isSearching = ref(false);
const searchTerm = ref('');
const selectedCategory = ref(''); // Holds the selected category filter
const sortOption = ref('name'); // 'name', 'author', 'category'
const itemsPerPagePers = ref(15);
const currentPagePers = ref(1);
const searchDebounceTimerPers = ref(null);
const scrollContainerPers = ref(null);
const loadMoreTriggerPers = ref(null);
const maxDisplayedMounted = ref(5); // Max mounted icons to show before '+N'
// --- Computed ---
const hasMorePersonalitiesToLoad = computed(() => {
return pagedPersonalities.value.length < filteredPersonalities.value.length;
});
const displayedMountedPersonalities = computed(() => {
// Slice the array for display limit
return mountedPersonalities.value.slice(0, maxDisplayedMounted.value);
});
// --- Watchers ---
watch(() => props.config.personalities, (newVal) => {
updateMountedList(newVal);
}, { immediate: true, deep: true });
watch([searchTerm, selectedCategory, sortOption], () => {
debounceSearch(); // Use debounce for all filter/sort changes
});
// Watch the master list changing
watch(allPersonalities, () => {
currentPagePers.value = 1;
pagedPersonalities.value = [];
applyFiltersAndSortPers();
loadMorePersonalities();
}, { deep: true });
// --- Methods ---
const getPersonalityIcon = (avatarPath) => {
if (!avatarPath) return defaultPersonalityIcon;
// Assuming avatarPath is relative like 'personalities/category/name/assets/logo.png'
// Adjust baseURL as needed if it doesn't include the leading slash
return `${axios.defaults.baseURL}${avatarPath.startsWith('/') ? '' : '/'}${avatarPath}`;
};
const imgPlaceholder = (event) => {
event.target.src = defaultPersonalityIcon;
};
const fetchPersonalitiesAndCategories = async () => {
isLoadingPersonalities.value = true;
console.log("Fetching personalities and categories...");
try {
const [cats, allPersDict] = await Promise.all([
props.api_get_req("list_personalities_categories"),
props.api_get_req("get_all_personalities") // Assumes this returns { category: [persObj1, persObj2] }
]);
categories.value = cats || [];
categories.value.sort(); // Sort categories alphabetically
let combined = [];
const mountedSet = new Set(props.config.personalities || []); // Set of mounted "category/folder" strings
if (allPersDict) {
for (const category in allPersDict) {
const personalitiesInCategory = allPersDict[category];
if (Array.isArray(personalitiesInCategory)) {
personalitiesInCategory.forEach(pers => {
const full_path = `${category}/${pers.folder}`;
// Determine unique ID - prefer pers.id, fallback to full_path
const uniqueId = pers.id || full_path;
combined.push({
...pers,
category: category, // Ensure category is stored
full_path: full_path,
isMounted: mountedSet.has(full_path),
id: uniqueId,
isProcessing: false // For mount/unmount spinners
});
});
}
}
}
allPersonalities.value = combined;
console.log(`Fetched ${allPersonalities.value.length} total personalities.`);
updateMountedList(props.config.personalities); // Ensure mounted list is synced initially
} catch (error) {
props.show_toast("Failed to load personalities.", 4, false);
console.error("Error fetching personalities:", error);
allPersonalities.value = [];
categories.value = [];
} finally {
isLoadingPersonalities.value = false;
nextTick(feather.replace);
}
};
const applyFiltersAndSortPers = () => {
isSearching.value = true;
console.time("FilterSortPersonalities");
let result = [...allPersonalities.value];
// 1. Filter by Category
if (selectedCategory.value) {
result = result.filter(p => p.category === selectedCategory.value);
}
// 2. Filter by Search Term
if (searchTerm.value) {
const lowerSearch = searchTerm.value.toLowerCase();
result = result.filter(p =>
p.name?.toLowerCase().includes(lowerSearch) ||
p.author?.toLowerCase().includes(lowerSearch) ||
p.description?.toLowerCase().includes(lowerSearch) ||
p.category?.toLowerCase().includes(lowerSearch) ||
p.folder?.toLowerCase().includes(lowerSearch)
);
}
// 3. Sort
result.sort((a, b) => {
// Always put mounted personalities first
if (a.isMounted && !b.isMounted) return -1;
if (!a.isMounted && b.isMounted) return 1;
// Then apply the selected sort option
switch (sortOption.value) {
case 'name':
return (a.name || '').localeCompare(b.name || '');
case 'author':
return (a.author || '').localeCompare(b.author || '');
case 'category':
return (a.category || '').localeCompare(b.category || '');
default:
return 0;
}
});
filteredPersonalities.value = result;
console.timeEnd("FilterSortPersonalities");
isSearching.value = false;
console.log(`Filtered/Sorted personalities: ${filteredPersonalities.value.length}`);
};
const debounceSearch = () => {
isSearching.value = true;
clearTimeout(searchDebounceTimerPers.value);
searchDebounceTimerPers.value = setTimeout(() => {
currentPagePers.value = 1;
pagedPersonalities.value = [];
applyFiltersAndSortPers();
loadMorePersonalities();
}, 300); // Shorter delay might be ok for personalities
};
const loadMorePersonalities = () => {
if (isLoadingPersonalities.value || isSearching.value) return;
const start = (currentPagePers.value - 1) * itemsPerPagePers.value;
const end = start + itemsPerPagePers.value;
const nextPageItems = filteredPersonalities.value.slice(start, end);
pagedPersonalities.value.push(...nextPageItems);
currentPagePers.value++;
nextTick(feather.replace);
};
const updateMountedList = (mountedPathsArray) => {
const mountedSet = new Set(mountedPathsArray || []);
mountedPersonalities.value = allPersonalities.value.filter(p => mountedSet.has(p.full_path));
// Also update the isMounted flag on the main list for consistency in the grid display
allPersonalities.value.forEach(p => {
p.isMounted = mountedSet.has(p.full_path);
});
// Trigger re-sort/filter if needed (e.g., if sort depends on mounted status)
// applyFiltersAndSortPers(); // This might cause loops if not careful, maybe trigger selectively
console.log("Updated mounted list:", mountedPersonalities.value.length);
};
const isActivePersonality = (pers) => {
// The active personality is identified by its index in the config's personalities array
const activeIndex = props.config.active_personality_id;
if (activeIndex === undefined || activeIndex < 0 || !props.config.personalities) {
return false;
}
// Check if the personality's full_path matches the one at the active index
return props.config.personalities[activeIndex] === pers.full_path;
};
const setPersonalityProcessing = (persId, state) => {
const index = allPersonalities.value.findIndex(p => (p.id || p.full_path) === persId);
if (index !== -1) {
allPersonalities.value[index].isProcessing = state;
// Update paged list as well
const pagedIndex = pagedPersonalities.value.findIndex(p => (p.id || p.full_path) === persId);
if (pagedIndex !== -1) {
pagedPersonalities.value[pagedIndex].isProcessing = state;
}
}
};
// --- Personality Actions ---
const handleSelect = async (pers) => {
if (!pers.isMounted) {
props.show_toast(`Personality "${pers.name}" is not mounted. Mount it first.`, 3, false);
return;
}
const persId = pers.id || pers.full_path;
setPersonalityProcessing(persId, true);
props.show_toast(`Selecting ${pers.name}...`, 2, true);
// Find the index of this personality in the *config's* mounted list
const indexInConfig = (props.config.personalities || []).findIndex(p => p === pers.full_path);
if (indexInConfig === -1) {
props.show_toast(`Error: ${pers.name} is marked as mounted but not found in config list.`, 4, false);
setPersonalityProcessing(persId, false);
return;
}
try {
const response = await props.api_post_req('select_personality', { id: indexInConfig });
if (response && response.status) {
props.show_toast(`Selected personality: ${pers.name}`, 4, true);
// Parent config should update via its refresh mechanism or direct emit
emit('update:setting', { key: 'active_personality_id', value: indexInConfig });
// We might need to refresh the whole config to be safe if backend changes other things
// await props.refresh_config();
} else {
props.show_toast(`Failed to select ${pers.name}: ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error selecting ${pers.name}: ${error.message}`, 4, false);
} finally {
setPersonalityProcessing(persId, false);
// Manually trigger reactivity for the isActive computed property if needed
// This might require forcing an update if props.config change isn't detected quickly enough
// Example: allPersonalities.value = [...allPersonalities.value];
}
};
const handleMount = async (pers) => {
if (pers.isMounted) {
props.show_toast(`${pers.name} is already mounted.`, 3, false);
return;
}
if (pers.disclaimer) {
const yes = await props.show_yes_no_dialog(`Disclaimer for ${pers.name}:\n\n${pers.disclaimer}\n\nMount this personality?`, 'Mount', 'Cancel');
if (!yes) return;
}
const persId = pers.id || pers.full_path;
setPersonalityProcessing(persId, true);
props.show_toast(`Mounting ${pers.name}...`, 3, true);
try {
// Request to mount
const mountResponse = await props.api_post_req('mount_personality', {
category: pers.category,
folder: pers.folder,
language: pers.language // Include if relevant
});
if (mountResponse && mountResponse.status) {
props.show_toast(`${pers.name} mounted successfully.`, 4, true);
// Update local state & config
const newMountedList = [...(props.config.personalities || []), pers.full_path];
emit('update:setting', { key: 'personalities', value: newMountedList });
// Mark as mounted in the main list
const index = allPersonalities.value.findIndex(p => (p.id || p.full_path) === persId);
if (index !== -1) {
allPersonalities.value[index].isMounted = true;
allPersonalities.value = [...allPersonalities.value]; // Trigger reactivity
}
// Optionally select it immediately after mounting
// Need the new index from the updated config list
const newIndexInConfig = newMountedList.length - 1; // It's the last one added
emit('update:setting', { key: 'active_personality_id', value: newIndexInConfig });
} else {
props.show_toast(`Failed to mount ${pers.name}: ${mountResponse?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error mounting ${pers.name}: ${error.message}`, 4, false);
} finally {
setPersonalityProcessing(persId, false);
}
};
const handleUnmount = async (pers) => {
if (!pers.isMounted) return;
const yes = await props.show_yes_no_dialog(`Unmount personality "${pers.name}"?`, 'Unmount', 'Cancel');
if (!yes) return;
const persId = pers.id || pers.full_path;
setPersonalityProcessing(persId, true);
props.show_toast(`Unmounting ${pers.name}...`, 3, true);
try {
const response = await props.api_post_req('unmount_personality', {
category: pers.category,
folder: pers.folder,
language: pers.language
});
if (response && response.status) {
props.show_toast(`${pers.name} unmounted.`, 4, true);
// Update local state & config
const currentMountedList = (props.config.personalities || []);
const newMountedList = currentMountedList.filter(p => p !== pers.full_path);
emit('update:setting', { key: 'personalities', value: newMountedList });
// If the unmounted one was active, select the last remaining one or none
if (isActivePersonality(pers)) {
const newActiveId = newMountedList.length > 0 ? newMountedList.length - 1 : -1;
emit('update:setting', { key: 'active_personality_id', value: newActiveId });
} else {
// Adjust active_personality_id if items before it were removed (more complex)
// A full config refresh might be safer here, or recalculate index based on new list.
// For simplicity, let's assume parent handles index adjustment or user re-selects.
}
// Mark as unmounted in the main list
const index = allPersonalities.value.findIndex(p => (p.id || p.full_path) === persId);
if (index !== -1) {
allPersonalities.value[index].isMounted = false;
allPersonalities.value = [...allPersonalities.value]; // Trigger reactivity
}
} else {
props.show_toast(`Failed to unmount ${pers.name}: ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error unmounting ${pers.name}: ${error.message}`, 4, false);
} finally {
setPersonalityProcessing(persId, false);
}
};
const unmountAll = async () => {
const yes = await props.show_yes_no_dialog(`Unmount all personalities?`, 'Unmount All', 'Cancel');
if (!yes) return;
props.show_toast(`Unmounting all...`, 3, true);
try {
const response = await props.api_post_req('unmount_all_personalities');
if (response && response.status) {
props.show_toast(`All personalities unmounted.`, 4, true);
emit('update:setting', { key: 'personalities', value: [] });
emit('update:setting', { key: 'active_personality_id', value: -1 });
// Update local state
allPersonalities.value.forEach(p => p.isMounted = false);
allPersonalities.value = [...allPersonalities.value];
} else {
props.show_toast(`Failed to unmount all: ${response?.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error unmounting all: ${error.message}`, 4, false);
}
};
const handleRemount = async (pers) => {
const persId = pers.id || pers.full_path;
setPersonalityProcessing(persId, true);
props.show_toast(`Remounting ${pers.name}...`, 3, true);
// Simplified: Call unmount then mount logic
try {
await handleUnmount(pers); // Attempt unmount first
await handleMount(pers); // Then attempt mount
} catch(e){/* Errors handled in sub-functions */}
finally {
setPersonalityProcessing(persId, false); // Ensure processing is reset
}
};
const handleEdit = async (pers) => {
props.show_toast(`Editing ${pers.name} requires opening its folder. Opening now...`, 4, true);
await handleOpenFolder(pers); // Use the existing open folder logic
// Consider navigating to a dedicated editor view if one exists in the future
// Or opening a specific file (e.g., config.yaml) if the backend supports it
};
const handleCopyToCustom = async (pers) => {
const yes = await props.show_yes_no_dialog(`Copy "${pers.name}" from "${pers.category}" to your 'custom_personalities' folder?`, 'Copy', 'Cancel');
if (!yes) return;
const persId = pers.id || pers.full_path;
setPersonalityProcessing(persId, true);
try {
const response = await props.api_post_req('copy_to_custom_personas', {
category: pers.category,
name: pers.folder // Assuming 'name' in API maps to 'folder' from personality object
});
if (response && response.status) {
props.show_message_box( // Use message box for longer text
`Personality "${pers.name}" copied to 'custom_personalities'.\nYou can now find and edit it under the 'custom_personalities' category.`
);
await fetchPersonalitiesAndCategories(); // Refresh list to show the new copy
} else {
props.show_toast(`Failed to copy ${pers.name}: ${response?.error || 'Already exists?'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error copying ${pers.name}: ${error.message}`, 4, false);
} finally {
setPersonalityProcessing(persId, false);
}
};
const handleReinstall = async (pers) => {
const yes = await props.show_yes_no_dialog(`Reinstall "${pers.name}" from its source?\nThis will overwrite any local changes.`, 'Reinstall', 'Cancel');
if (!yes) return;
const persId = pers.id || pers.full_path;
setPersonalityProcessing(persId, true);
props.show_toast(`Reinstalling ${pers.name}...`, 3, true);
try {
// Assuming backend uses the full path or category/name combo
const response = await props.api_post_req('reinstall_personality', { name: pers.full_path });
if (response && response.status) {
props.show_toast(`${pers.name} reinstalled successfully.`, 4, true);
// Might need to refresh config or specific personality data if content changed
} else {
props.show_toast(`Failed to reinstall ${pers.name}: ${response?.error || 'Not found?'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error reinstalling ${pers.name}: ${error.message}`, 4, false);
} finally {
setPersonalityProcessing(persId, false);
}
};
const handleSettings = async (pers) => {
// Similar to binding settings, usually fetches settings for the *active* personality
const activePersPath = props.config.personalities ? props.config.personalities[props.config.active_personality_id] : null;
if (!activePersPath || activePersPath !== pers.full_path) {
props.show_toast(`Select "${pers.name}" first to configure its active settings.`, 4, false);
return; // Or implement fetching settings by path if backend supports it
}
const persId = pers.id || pers.full_path;
setPersonalityProcessing(persId, true);
try {
const settingsData = await props.api_get_req('get_active_personality_settings'); // Endpoint fetches for the active one
if (settingsData && Object.keys(settingsData).length > 0) {
const result = await props.show_universal_form(settingsData, `Personality Settings - ${pers.name}`, "Save", "Cancel");
// If form submitted
const setResponse = await props.api_post_req('set_active_personality_settings', { settings: result });
if (setResponse && setResponse.status) {
props.show_toast(`Settings for ${pers.name} updated.`, 4, true);
// Changes applied automatically if backend modifies the active personality's config in memory.
// May need a remount/reload if changes require it.
} else {
props.show_toast(`Failed to update settings for ${pers.name}: ${setResponse?.error || 'Unknown error'}`, 4, false);
}
} else {
props.show_toast(`Personality "${pers.name}" has no configurable settings.`, 4, false);
}
} catch (error) {
props.show_toast(`Error accessing settings for ${pers.name}: ${error.message}`, 4, false);
} finally {
setPersonalityProcessing(persId, false);
}
};
const handleCopyName = (pers) => {
navigator.clipboard.writeText(pers.name)
.then(() => props.show_toast(`Copied name: ${pers.name}`, 3, true))
.catch(() => props.show_toast("Failed to copy name.", 3, false));
};
const handleOpenFolder = async (pers) => {
try {
await props.api_post_req("open_personality_folder", { category: pers.category, name: pers.folder });
// No toast needed, action happens on backend/OS
} catch (error) {
props.show_toast(`Error opening folder for ${pers.name}: ${error.message}`, 4, false);
}
};
// --- Infinite Scroll ---
let observerPers = null;
const setupIntersectionObserverPers = () => {
const options = { root: null, rootMargin: '0px', threshold: 0.1 };
observerPers = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMorePersonalitiesToLoad.value && !isLoadingPersonalities.value && !isSearching.value) {
loadMorePersonalities();
}
});
}, options);
if (loadMoreTriggerPers.value) observerPers.observe(loadMoreTriggerPers.value);
};
// --- Lifecycle Hooks ---
onMounted(() => {
fetchPersonalitiesAndCategories();
nextTick(() => {
feather.replace();
if (loadMoreTriggerPers.value) setupIntersectionObserverPers();
});
});
onUnmounted(() => {
if (observerPers && loadMoreTriggerPers.value) observerPers.unobserve(loadMoreTriggerPers.value);
if (observerPers) observerPers.disconnect();
clearTimeout(searchDebounceTimerPers.value);
});
onUpdated(() => {
nextTick(() => {
feather.replace();
if (!observerPers && loadMoreTriggerPers.value) setupIntersectionObserverPers();
else if (observerPers && loadMoreTriggerPers.value) { // Re-observe if trigger element changed
observerPers.disconnect();
observerPers.observe(loadMoreTriggerPers.value);
}
});
});
</script>
<style scoped>
/* Using shared styles */
.input-field {
@apply block w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary disabled:opacity-50;
}
.button-base-sm {
@apply inline-flex items-center justify-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 transition-colors duration-150;
}
.button-danger-sm { @apply button-base-sm text-white bg-red-600 hover:bg-red-700 focus:ring-red-500; }
/* Add transition group styles if needed */
.pers-grid-enter-active,
.pers-grid-leave-active {
transition: all 0.5s ease;
}
.pers-grid-enter-from,
.pers-grid-leave-to {
opacity: 0;
transform: translateY(15px);
}
/* .pers-grid-leave-active { position: absolute; } */ /* Be careful with absolute positioning */
</style>

View File

@ -0,0 +1,473 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 pb-2">
Services Zoo & Audio
</h2>
<!-- Default Service Selection -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Default Service Selection</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Choose the default services LoLLMs will use for various tasks. Specific personalities might override these.
</p>
<div class="grid grid-cols-1 gap-4">
<!-- TTS Service -->
<div class="setting-item-grid">
<label for="active_tts_service" class="setting-label">Text-to-Speech (TTS)</label>
<div class="setting-input-group">
<select id="active_tts_service" :value="config.active_tts_service" @change="updateValue('active_tts_service', $event.target.value)" class="input-field flex-grow">
<option value="None">None</option>
<option value="browser">Browser TTS</option>
<option v-for="service in ttsServices" :key="`tts-${service.name}`" :value="service.name">{{ service.caption || service.name }}</option>
</select>
<button @click="showServiceSettings('tts', config.active_tts_service)" :disabled="!config.active_tts_service || config.active_tts_service === 'None' || config.active_tts_service === 'browser'" class="button-secondary-sm p-2" title="Configure Selected TTS Service">
<i data-feather="settings" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- STT Service -->
<div class="setting-item-grid">
<label for="active_stt_service" class="setting-label">Speech-to-Text (STT)</label>
<div class="setting-input-group">
<select id="active_stt_service" :value="config.active_stt_service" @change="updateValue('active_stt_service', $event.target.value)" class="input-field flex-grow">
<option value="None">None</option>
<option v-for="service in sttServices" :key="`stt-${service.name}`" :value="service.name">{{ service.caption || service.name }}</option>
</select>
<button @click="showServiceSettings('stt', config.active_stt_service)" :disabled="!config.active_stt_service || config.active_stt_service === 'None'" class="button-secondary-sm p-2" title="Configure Selected STT Service">
<i data-feather="settings" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- TTI Service -->
<div class="setting-item-grid">
<label for="active_tti_service" class="setting-label">Text-to-Image (TTI)</label>
<div class="setting-input-group">
<select id="active_tti_service" :value="config.active_tti_service" @change="updateValue('active_tti_service', $event.target.value)" class="input-field flex-grow">
<option value="None">None</option>
<option v-for="service in ttiServices" :key="`tti-${service.name}`" :value="service.name">{{ service.caption || service.name }}</option>
</select>
<button @click="showServiceSettings('tti', config.active_tti_service)" :disabled="!config.active_tti_service || config.active_tti_service === 'None'" class="button-secondary-sm p-2" title="Configure Selected TTI Service">
<i data-feather="settings" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- TTM Service -->
<div class="setting-item-grid">
<label for="active_ttm_service" class="setting-label">Text-to-Music (TTM)</label>
<div class="setting-input-group">
<select id="active_ttm_service" :value="config.active_ttm_service" @change="updateValue('active_ttm_service', $event.target.value)" class="input-field flex-grow">
<option value="None">None</option>
<option v-for="service in ttmServices" :key="`ttm-${service.name}`" :value="service.name">{{ service.caption || service.name }}</option>
</select>
<button @click="showServiceSettings('ttm', config.active_ttm_service)" :disabled="!config.active_ttm_service || config.active_ttm_service === 'None'" class="button-secondary-sm p-2" title="Configure Selected TTM Service">
<i data-feather="settings" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- TTV Service -->
<div class="setting-item-grid">
<label for="active_ttv_service" class="setting-label">Text-to-Video (TTV)</label>
<div class="setting-input-group">
<select id="active_ttv_service" :value="config.active_ttv_service" @change="updateValue('active_ttv_service', $event.target.value)" class="input-field flex-grow">
<option value="None">None</option>
<option v-for="service in ttvServices" :key="`ttv-${service.name}`" :value="service.name">{{ service.caption || service.name }}</option>
</select>
<button @click="showServiceSettings('ttv', config.active_ttv_service)" :disabled="!config.active_ttv_service || config.active_ttv_service === 'None'" class="button-secondary-sm p-2" title="Configure Selected TTV Service">
<i data-feather="settings" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
</section>
<!-- TTI Settings (Negative Prompt) -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Text-to-Image Settings</h3>
<!-- Use Negative Prompt Toggle -->
<div class="toggle-item">
<label for="use_negative_prompt" class="toggle-label">Use Negative Prompt</label>
<ToggleSwitch id="use_negative_prompt" :checked="config.use_negative_prompt" @update:checked="updateBoolean('use_negative_prompt', $event)" />
</div>
<!-- Use AI Generated Negative Prompt -->
<div class="toggle-item" :class="{ 'opacity-50 pointer-events-none': !config.use_negative_prompt }">
<label for="use_ai_generated_negative_prompt" class="toggle-label">Generate Negative Prompt with AI</label>
<ToggleSwitch id="use_ai_generated_negative_prompt" :checked="config.use_ai_generated_negative_prompt" @update:checked="updateBoolean('use_ai_generated_negative_prompt', $event)" :disabled="!config.use_negative_prompt"/>
</div>
<!-- Negative Prompt Generation Prompt -->
<div class="setting-item-grid" :class="{ 'opacity-50 pointer-events-none': !config.use_negative_prompt || !config.use_ai_generated_negative_prompt }">
<label for="negative_prompt_generation_prompt" class="setting-label">AI Negative Prompt Generator Instruction</label>
<input type="text" id="negative_prompt_generation_prompt" :value="config.negative_prompt_generation_prompt" @input="updateValue('negative_prompt_generation_prompt', $event.target.value)" class="input-field" placeholder="e.g., Generate a list of negative keywords..." :disabled="!config.use_negative_prompt || !config.use_ai_generated_negative_prompt">
</div>
<!-- Default Negative Prompt -->
<div class="setting-item-grid" :class="{ 'opacity-50 pointer-events-none': !config.use_negative_prompt || config.use_ai_generated_negative_prompt }">
<label for="default_negative_prompt" class="setting-label">Default Negative Prompt</label>
<input type="text" id="default_negative_prompt" :value="config.default_negative_prompt" @input="updateValue('default_negative_prompt', $event.target.value)" class="input-field" placeholder="e.g., blurry, low quality, text, signature..." :disabled="!config.use_negative_prompt || config.use_ai_generated_negative_prompt">
</div>
</section>
<!-- STT / Audio Input Settings -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Audio Input / STT Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<!-- Listening Threshold -->
<div class="setting-item-grid !items-center">
<label for="stt_listening_threshold" class="setting-label">Listening Threshold</label>
<input id="stt_listening_threshold" :value="config.stt_listening_threshold" @input="updateValue('stt_listening_threshold', parseInt($event.target.value))" type="number" min="0" step="10" class="input-field-sm w-24">
</div>
<!-- Silence Duration -->
<div class="setting-item-grid !items-center">
<label for="stt_silence_duration" class="setting-label">Silence Duration (s)</label>
<input id="stt_silence_duration" :value="config.stt_silence_duration" @input="updateValue('stt_silence_duration', parseInt($event.target.value))" type="number" min="0" step="1" class="input-field-sm w-24">
</div>
<!-- Sound Threshold % -->
<div class="setting-item-grid !items-center">
<label for="stt_sound_threshold_percentage" class="setting-label">Min Sound Percentage</label>
<input id="stt_sound_threshold_percentage" :value="config.stt_sound_threshold_percentage" @input="updateValue('stt_sound_threshold_percentage', parseInt($event.target.value))" type="number" min="0" max="100" step="1" class="input-field-sm w-24">
</div>
<!-- Volume Amplification -->
<div class="setting-item-grid !items-center">
<label for="stt_gain" class="setting-label">Volume Amplification</label>
<input id="stt_gain" :value="config.stt_gain" @input="updateValue('stt_gain', parseInt($event.target.value))" type="number" min="0" step="1" class="input-field-sm w-24">
</div>
<!-- Audio Rate -->
<div class="setting-item-grid !items-center">
<label for="stt_rate" class="setting-label">Audio Rate (Hz)</label>
<input id="stt_rate" :value="config.stt_rate" @input="updateValue('stt_rate', parseInt($event.target.value))" type="number" min="8000" step="1000" class="input-field-sm w-24">
</div>
<!-- Channels -->
<div class="setting-item-grid !items-center">
<label for="stt_channels" class="setting-label">Channels</label>
<input id="stt_channels" :value="config.stt_channels" @input="updateValue('stt_channels', parseInt($event.target.value))" type="number" min="1" max="2" step="1" class="input-field-sm w-24">
</div>
<!-- Buffer Size -->
<div class="setting-item-grid !items-center">
<label for="stt_buffer_size" class="setting-label">Buffer Size</label>
<input id="stt_buffer_size" :value="config.stt_buffer_size" @input="updateValue('stt_buffer_size', parseInt($event.target.value))" type="number" min="512" step="512" class="input-field-sm w-24">
</div>
<!-- Activate Word Detection -->
<div class="toggle-item md:col-span-2">
<label for="stt_activate_word_detection" class="toggle-label">Activate Wake Word Detection</label>
<ToggleSwitch id="stt_activate_word_detection" :checked="config.stt_activate_word_detection" @update:checked="updateBoolean('stt_activate_word_detection', $event)" />
</div>
<!-- Word Detection File -->
<div class="setting-item-grid md:col-span-2" :class="{ 'opacity-50 pointer-events-none': !config.stt_activate_word_detection }">
<label for="stt_word_detection_file" class="setting-label">Wake Word File (.wav)</label>
<input type="text" id="stt_word_detection_file" :value="config.stt_word_detection_file" @input="updateValue('stt_word_detection_file', $event.target.value)" class="input-field" placeholder="Path to wake word wav file" :disabled="!config.stt_activate_word_detection">
</div>
</div>
</section>
<!-- Audio Devices -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Audio Devices</h3>
<button @click="refreshAudioDevices" class="button-secondary-sm mb-2" title="Rescan for audio devices">
<i data-feather="refresh-cw" class="w-4 h-4 mr-1"></i> Refresh Devices
</button>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Input Device -->
<div class="setting-item-grid">
<label for="stt_input_device" class="setting-label">Audio Input Device</label>
<select id="stt_input_device" :value="config.stt_input_device" @change="updateValue('stt_input_device', parseInt($event.target.value))" class="input-field">
<option v-for="(device, index) in audioInputDevices" :key="`in-${index}`" :value="audioInputDeviceIndexes[index]">
{{ device }}
</option>
</select>
</div>
<!-- Output Device -->
<div class="setting-item-grid">
<label for="tts_output_device" class="setting-label">Audio Output Device</label>
<select id="tts_output_device" :value="config.tts_output_device" @change="updateValue('tts_output_device', parseInt($event.target.value))" class="input-field">
<option v-for="(device, index) in audioOutputDevices" :key="`out-${index}`" :value="audioOutputDeviceIndexes[index]">
{{ device }}
</option>
</select>
</div>
</div>
</section>
<!-- Specific Service Settings (Keys, Models, Management) -->
<!-- This section could be further broken down if it becomes too large -->
<section class="space-y-4 p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Service Specific Settings & Management</h3>
<!-- Example: OpenAI Keys -->
<div class="setting-item-grid">
<label for="openai_whisper_key" class="setting-label">OpenAI API Key (STT/TTS)</label>
<input type="password" id="openai_whisper_key" :value="config.openai_whisper_key" @input="updateValue('openai_whisper_key', $event.target.value)" class="input-field" placeholder="sk-...">
</div>
<div class="setting-item-grid">
<label for="dall_e_key" class="setting-label">OpenAI API Key (Dall-E TTI)</label>
<input type="password" id="dall_e_key" :value="config.dall_e_key" @input="updateValue('dall_e_key', $event.target.value)" class="input-field" placeholder="sk-...">
</div>
<!-- Example: ElevenLabs -->
<div class="setting-item-grid">
<label for="elevenlabs_tts_key" class="setting-label">ElevenLabs API Key (TTS)</label>
<input type="password" id="elevenlabs_tts_key" :value="config.elevenlabs_tts_key" @input="updateValue('elevenlabs_tts_key', $event.target.value)" class="input-field" placeholder="Enter ElevenLabs Key">
</div>
<!-- Add other ElevenLabs settings if static (voice ID dropdown might need fetching) -->
<!-- Example: Stable Diffusion Management -->
<div class="p-3 border-t border-gray-300 dark:border-gray-600 mt-4">
<h4 class="text-md font-medium text-gray-600 dark:text-gray-400 mb-2">Stable Diffusion (A1111) Service</h4>
<div class="flex flex-wrap gap-2">
<button @click="manageService('install_sd')" class="button-success-sm"><i data-feather="download" class="w-4 h-4 mr-1"></i>Install/Reinstall</button>
<button @click="manageService('upgrade_sd')" class="button-secondary-sm"><i data-feather="chevrons-up" class="w-4 h-4 mr-1"></i>Upgrade</button>
<button @click="manageService('start_sd')" class="button-success-sm"><i data-feather="play" class="w-4 h-4 mr-1"></i>Start Service</button>
<button @click="manageService('show_sd')" class="button-primary-sm"><i data-feather="external-link" class="w-4 h-4 mr-1"></i>Show WebUI</button>
</div>
</div>
<!-- Example: Ollama Management -->
<div class="p-3 border-t border-gray-300 dark:border-gray-600 mt-4">
<h4 class="text-md font-medium text-gray-600 dark:text-gray-400 mb-2">Ollama Service</h4>
<div class="flex flex-wrap gap-2">
<button @click="manageService('install_ollama')" class="button-success-sm"><i data-feather="download" class="w-4 h-4 mr-1"></i>Install/Reinstall</button>
<button @click="manageService('start_ollama')" class="button-success-sm"><i data-feather="play" class="w-4 h-4 mr-1"></i>Start Service</button>
</div>
</div>
<!-- Add other service management sections similarly (ComfyUI, Whisper, XTTS, etc.) -->
</section>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, defineProps, defineEmits } from 'vue';
import feather from 'feather-icons';
import ToggleSwitch from '@/components/ToggleSwitch.vue';
// Props
const props = defineProps({
config: { type: Object, required: true },
loading: { type: Boolean, default: false },
api_post_req: { type: Function, required: true },
api_get_req: { type: Function, required: true },
show_toast: { type: Function, required: true },
show_yes_no_dialog: { type: Function, required: true },
show_universal_form: { type: Function, required: true },
client_id: { type: String, required: true }
});
// Emits
const emit = defineEmits(['update:setting']);
// Reactive State
const ttsServices = ref([]);
const sttServices = ref([]);
const ttiServices = ref([]);
const ttmServices = ref([]);
const ttvServices = ref([]);
const audioInputDevices = ref([]);
const audioInputDeviceIndexes = ref([]);
const audioOutputDevices = ref([]);
const audioOutputDeviceIndexes = ref([]);
// --- Methods ---
const updateValue = (key, value) => {
// Handle potential parsing for numbers if needed from text inputs
const numericKeys = [
'stt_listening_threshold', 'stt_silence_duration', 'stt_sound_threshold_percentage',
'stt_gain', 'stt_rate', 'stt_channels', 'stt_buffer_size'
];
const finalValue = numericKeys.includes(key) ? parseInt(value) || 0 : value;
emit('update:setting', { key, value: finalValue });
};
const updateBoolean = (key, value) => {
emit('update:setting', { key: key, value: Boolean(value) });
};
const fetchServiceLists = async () => {
try {
const [ttsRes, sttRes, ttiRes, ttmRes, ttvRes] = await Promise.all([
props.api_post_req('list_tts_services'),
props.api_post_req('list_stt_services'),
props.api_post_req('list_tti_services'),
props.api_post_req('list_ttm_services'),
props.api_post_req('list_ttv_services')
]);
ttsServices.value = ttsRes || [];
sttServices.value = sttRes || [];
ttiServices.value = ttiRes || [];
ttmServices.value = ttmRes || [];
ttvServices.value = ttvRes || [];
} catch (error) {
props.show_toast("Failed to fetch service lists.", 4, false);
console.error("Error fetching service lists:", error);
}
};
const fetchAudioDevices = async () => {
try {
const [inputRes, outputRes] = await Promise.all([
props.api_get_req("get_snd_input_devices"),
props.api_get_req("get_snd_output_devices")
]);
audioInputDevices.value = inputRes?.device_names || [];
audioInputDeviceIndexes.value = inputRes?.device_indexes || [];
audioOutputDevices.value = outputRes?.device_names || [];
audioOutputDeviceIndexes.value = outputRes?.device_indexes || [];
} catch (error) {
props.show_toast("Failed to fetch audio devices.", 4, false);
console.error("Error fetching audio devices:", error);
}
};
const refreshAudioDevices = () => {
props.show_toast("Refreshing audio devices...", 2, true);
fetchAudioDevices();
};
const showServiceSettings = async (serviceType, serviceName) => {
if (!serviceName || serviceName === 'None' || serviceName === 'browser') {
props.show_toast(`No configurable settings for '${serviceName}'.`, 3, false);
return;
}
const endpointMap = {
tts: 'get_active_tts_settings',
stt: 'get_active_stt_settings',
tti: 'get_active_tti_settings',
ttm: 'get_active_ttm_settings',
ttv: 'get_active_ttv_settings'
};
const setEndpointMap = {
tts: 'set_active_tts_settings',
stt: 'set_active_stt_settings',
tti: 'set_active_tti_settings',
ttm: 'set_active_ttm_settings',
ttv: 'set_active_ttv_settings'
};
const getEndpoint = endpointMap[serviceType];
const setEndpoint = setEndpointMap[serviceType];
if (!getEndpoint || !setEndpoint) return;
try {
const settingsData = await props.api_post_req(getEndpoint);
if (settingsData && Object.keys(settingsData).length > 0) {
const result = await props.show_universal_form(settingsData, `${serviceName} Settings`, "Save", "Cancel");
// If user confirmed (didn't cancel)
const setResponse = await props.api_post_req(setEndpoint, { settings: result });
if (setResponse && setResponse.status) {
props.show_toast(`${serviceName} settings updated successfully!`, 4, true);
// Maybe refresh config locally or trigger parent refresh if needed
} else {
props.show_toast(`Failed to update ${serviceName} settings: ${setResponse?.error || 'Unknown error'}`, 4, false);
}
} else {
props.show_toast(`${serviceName} has no configurable settings.`, 4, false);
}
} catch (error) {
props.show_toast(`Error fetching/setting ${serviceName} settings: ${error.message}`, 4, false);
console.error(`Error with ${serviceName} settings:`, error);
}
};
const manageService = async (actionEndpoint) => {
props.show_toast(`Performing action: ${actionEndpoint}...`, 5, true);
try {
// Add disclaimer/confirmation if needed for installs/upgrades
// if (actionEndpoint.includes('install')) { ... }
const response = await props.api_post_req(actionEndpoint);
// Success/failure feedback depends heavily on the backend response structure
// and whether the action is immediate or backgrounded.
// A generic message is often best unless specific feedback is provided.
if (response && response.status !== false) { // Check for explicit false status if backend uses it
props.show_toast(`Action '${actionEndpoint}' initiated successfully.`, 4, true);
} else {
props.show_toast(`Action '${actionEndpoint}' failed: ${response?.error || 'Check logs'}`, 4, false);
}
} catch (error) {
props.show_toast(`Error during action '${actionEndpoint}': ${error.message}`, 4, false);
console.error(`Error managing service (${actionEndpoint}):`, error);
}
};
// Lifecycle Hooks
onMounted(() => {
fetchServiceLists();
fetchAudioDevices();
nextTick(() => {
feather.replace();
});
});
onUpdated(() => {
// Use this cautiously - might cause excessive re-renders if not careful
nextTick(() => {
feather.replace();
});
});
</script>
<style scoped>
/* Using shared styles defined in previous components or globally */
.setting-item-grid {
/* Grid layout for label on left, input/group on right */
@apply grid grid-cols-1 md:grid-cols-[minmax(150px,25%)_1fr] gap-x-4 gap-y-1 items-center py-1;
}
.setting-label {
/* Label alignment */
@apply text-sm font-medium text-gray-700 dark:text-gray-300 text-left md:text-right pr-2;
}
.setting-input-group {
/* Grouping for input/select + button */
@apply flex items-center gap-2;
}
.input-field {
/* Standard focus, background, border */
@apply block w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 disabled:opacity-50;
}
.input-field-sm {
/* Standard focus, background, border - smaller version */
@apply block w-full px-2.5 py-1.5 text-xs bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-offset-gray-800 disabled:opacity-50;
}
.toggle-item {
@apply flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors;
}
.toggle-label {
@apply text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1 mr-4;
}
.toggle-description {
@apply block text-xs text-gray-500 dark:text-gray-400 mt-1 font-normal;
}
/* Shared Button Styles (Tailwind) - Standardized */
.button-base {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 transition-colors duration-150;
}
.button-base-sm {
@apply inline-flex items-center justify-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 transition-colors duration-150;
}
/* Use standard blue for primary, green for success etc. */
.button-primary { @apply button-base text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500; }
.button-secondary { @apply button-base text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400; }
.button-success { @apply button-base text-white bg-green-600 hover:bg-green-700 focus:ring-green-500; }
.button-danger { @apply button-base text-white bg-red-600 hover:bg-red-700 focus:ring-red-500; }
.button-primary-sm { @apply button-base-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500; }
.button-secondary-sm { @apply button-base-sm text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 focus:ring-gray-400; }
.button-success-sm { @apply button-base-sm text-white bg-green-600 hover:bg-green-700 focus:ring-green-500; }
</style>

View File

@ -0,0 +1,124 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 pb-2">
Smart Routing Configuration
</h2>
<div class="space-y-4">
<!-- Use Smart Routing Toggle -->
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors">
<label for="use_smart_routing" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1 mr-4">
Enable Smart Routing
<span class="block text-xs text-gray-500 dark:text-gray-400 mt-1">
Allow LoLLMs to automatically select the best model for a given task based on descriptions.
</span>
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="use_smart_routing"
:checked="config.use_smart_routing"
@change="updateBoolean('use_smart_routing', $event.target.checked)"
class="sr-only peer"
>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-primary"></div>
</label>
</div>
<!-- Restore Model After Smart Routing Toggle -->
<div :class="['flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors', !config.use_smart_routing ? 'opacity-50 pointer-events-none' : '']">
<label for="restore_model_after_smart_routing" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer flex-1 mr-4">
Restore Original Model After Routing
<span class="block text-xs text-gray-500 dark:text-gray-400 mt-1">
Automatically switch back to the originally selected model after the routed task is complete.
</span>
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="restore_model_after_smart_routing"
:checked="config.restore_model_after_smart_routing"
@change="updateBoolean('restore_model_after_smart_routing', $event.target.checked)"
:disabled="!config.use_smart_routing"
class="sr-only peer"
>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-500 peer-checked:bg-primary"></div>
</label>
</div>
<!-- Router Model Input -->
<div :class="['p-3 rounded-lg space-y-2', !config.use_smart_routing ? 'opacity-50 pointer-events-none' : '']">
<label for="smart_routing_router_model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Router Model Name
<span class="block text-xs text-gray-500 dark:text-gray-400 mt-1">
The model responsible for deciding which specialized model to use. (e.g., `mistralai/Mistral-7B-Instruct-v0.2`)
</span>
</label>
<input
type="text"
id="smart_routing_router_model"
:value="config.smart_routing_router_model"
@input="updateValue('smart_routing_router_model', $event.target.value)"
:disabled="!config.use_smart_routing"
class="input-field w-full"
placeholder="Enter the router model name"
>
</div>
<!-- Models with Description Dictionary -->
<div :class="['p-3 rounded-lg space-y-2', !config.use_smart_routing ? 'opacity-50 pointer-events-none' : '']">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Specialized Models & Descriptions
<span class="block text-xs text-gray-500 dark:text-gray-400 mt-1">
Define the models that the router can choose from and provide a clear description of their capabilities.
</span>
</label>
<DictManager
:modelValue="config.smart_routing_models_description || {}"
@update:modelValue="updateValue('smart_routing_models_description', $event)"
key-name="Model Path / Name"
value-name="Model Description (Task Capabilities)"
placeholder="Enter model name (e.g., openai/gpt-4) or path"
value-placeholder="Describe what this model is good at (e.g., 'Excellent for coding tasks and complex reasoning')"
:disabled="!config.use_smart_routing"
class="flex-grow"
/>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import DictManager from '@/components/DictManager.vue'; // Adjust path as needed
// Props definition
const props = defineProps({
config: { type: Object, required: true },
loading: { type: Boolean, default: false },
settingsChanged: { type: Boolean, default: false } // Optional: Can be used for local validation/UI hints
});
// Emits definition for updating parent
const emit = defineEmits(['update:setting', 'settings-changed']);
// --- Methods ---
const updateValue = (key, value) => {
// Simple update for text, numbers, or complex objects like dictionaries
emit('update:setting', { key, value });
};
const updateBoolean = (key, value) => {
// Ensures boolean values are correctly emitted
emit('update:setting', { key, value: Boolean(value) });
};
</script>
<style scoped>
.input-field {
@apply block w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed;
}
/* Add specific styles for DictManager if needed, or style within DictManager itself */
</style>

View File

@ -0,0 +1,230 @@
<template>
<div class="space-y-6 p-4 md:p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 pb-2">
System Status
</h2>
<!-- Hardware Usage Summary -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<!-- VRAM Usage -->
<div v-if="vramUsage && vramUsage.gpus && vramUsage.gpus.length > 0" class="flex items-center space-x-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<img :src="SVGGPU" width="25" height="25" class="flex-shrink-0" alt="GPU Icon">
<div v-if="vramUsage.gpus.length === 1" class="flex-1">
<div class="font-medium">GPU VRAM</div>
<div>{{ computedFileSize(vramUsage.gpus[0].used_vram) }} / {{ computedFileSize(vramUsage.gpus[0].total_vram) }} ({{ vramUsage.gpus[0].percentage }}%)</div>
</div>
<div v-else class="flex-1">
<div class="font-medium">{{ vramUsage.gpus.length }}x GPUs</div>
<!-- Maybe show average or total usage if needed -->
</div>
</div>
<div v-else class="flex items-center space-x-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<i data-feather="cpu" class="w-5 h-5 text-gray-500"></i>
<div class="flex-1 font-medium text-gray-500">No GPU Detected</div>
</div>
<!-- RAM Usage -->
<div v-if="ramUsage" class="flex items-center space-x-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<i data-feather="cpu" class="w-5 h-5 text-blue-500 flex-shrink-0"></i>
<div class="flex-1">
<div class="font-medium">CPU RAM</div>
<div>{{ computedFileSize(ramUsage.ram_usage) }} / {{ computedFileSize(ramUsage.total_space) }} ({{ ramUsage.percent_usage }}%)</div>
</div>
</div>
<!-- Disk Usage -->
<div v-if="diskUsage" class="flex items-center space-x-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<i data-feather="hard-drive" class="w-5 h-5 text-green-500 flex-shrink-0"></i>
<div class="flex-1">
<div class="font-medium">Disk (Models/DB)</div>
<div>{{ computedFileSize(diskUsage.binding_models_usage) }} / {{ computedFileSize(diskUsage.total_space) }} ({{ diskUsage.percent_usage }}%)</div>
</div>
</div>
</div>
<!-- Detailed Hardware Usage -->
<div class="space-y-4">
<!-- RAM Details -->
<div v-if="ramUsage" class="p-4 border border-gray-200 dark:border-gray-600 rounded-md">
<label class=" flex items-center gap-1 mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i data-feather="cpu" class="w-4 h-4 text-blue-500"></i>
CPU RAM Usage Details
</label>
<div class="text-xs space-y-1 mb-2 text-gray-600 dark:text-gray-400">
<div><b>Available: </b>{{ computedFileSize(ramUsage.available_space) }}</div>
<div><b>Usage: </b> {{ computedFileSize(ramUsage.ram_usage) }} / {{ computedFileSize(ramUsage.total_space) }} ({{ ramUsage.percent_usage }}%)</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-600">
<div class="bg-blue-600 h-2.5 rounded-full transition-all duration-300" :style="{ width: ramUsage.percent_usage + '%' }"></div>
</div>
</div>
<!-- Disk Details -->
<div v-if="diskUsage" class="p-4 border border-gray-200 dark:border-gray-600 rounded-md">
<label class="flex items-center gap-1 mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i data-feather="hard-drive" class="w-4 h-4 text-green-500"></i>
Disk Usage Details
</label>
<div class="text-xs space-y-1 mb-2 text-gray-600 dark:text-gray-400">
<div><b>Available: </b>{{ computedFileSize(diskUsage.available_space) }}</div>
<div><b>Usage (Models/DB): </b> {{ computedFileSize(diskUsage.binding_models_usage) }} / {{ computedFileSize(diskUsage.total_space) }} ({{ diskUsage.percent_usage }}%)</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-600">
<div class="bg-green-600 h-2.5 rounded-full transition-all duration-300" :style="{ width: diskUsage.percent_usage + '%' }"></div>
</div>
</div>
<!-- GPU Details -->
<div v-if="vramUsage && vramUsage.gpus && vramUsage.gpus.length > 0">
<div v-for="(item, index) in vramUsage.gpus" :key="index" class="p-4 border border-gray-200 dark:border-gray-600 rounded-md mb-4">
<label class="flex items-center gap-1 mb-2 text-sm font-medium text-gray-900 dark:text-white">
<img :src="SVGGPU" width="20" height="20" class="flex-shrink-0" alt="GPU Icon">
GPU {{ index }} Usage Details
</label>
<div class="text-xs space-y-1 mb-2 text-gray-600 dark:text-gray-400">
<div><b>Model: </b>{{ item.gpu_model }}</div>
<div><b>Available VRAM: </b>{{ computedFileSize(item.available_space) }}</div>
<div><b>Usage: </b> {{ computedFileSize(item.used_vram) }} / {{ computedFileSize(item.total_vram) }} ({{ item.percentage }}%)</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-600">
<div class="bg-purple-600 h-2.5 rounded-full transition-all duration-300" :style="{ width: item.percentage + '%' }"></div>
</div>
</div>
</div>
</div>
<!-- Folders Section -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200">Common Folders</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<!-- Custom Personalities Folder -->
<div
class="folder-button group border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20"
@click="handleFolderClick('custom-personalities')"
title="Open Custom Personalities folder"
>
<i data-feather="users" class="w-10 h-10 text-blue-500 group-hover:scale-110 transition-transform duration-200"></i>
<span class="mt-2 text-xs text-center text-gray-700 dark:text-gray-300">Custom Personalities</span>
</div>
<!-- Custom Function Calls Folder -->
<div
class="folder-button group border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20"
@click="handleFolderClick('custom-function-calls')"
title="Open Custom Function Calls folder"
>
<i data-feather="tool" class="w-10 h-10 text-green-500 group-hover:scale-110 transition-transform duration-200"></i>
<span class="mt-2 text-xs text-center text-gray-700 dark:text-gray-300">Custom Functions</span>
</div>
<!-- Configurations Folder -->
<div
class="folder-button group border-yellow-500 hover:bg-yellow-50 dark:hover:bg-yellow-900/20"
@click="handleFolderClick('configurations')"
title="Open Configurations folder"
>
<i data-feather="settings" class="w-10 h-10 text-yellow-500 group-hover:scale-110 transition-transform duration-200"></i>
<span class="mt-2 text-xs text-center text-gray-700 dark:text-gray-300">Configurations</span>
</div>
<!-- AI Outputs Folder -->
<div
class="folder-button group border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20"
@click="handleFolderClick('ai-outputs')"
title="Open AI Outputs folder"
>
<i data-feather="gift" class="w-10 h-10 text-purple-500 group-hover:scale-110 transition-transform duration-200"></i>
<span class="mt-2 text-xs text-center text-gray-700 dark:text-gray-300">AI Outputs</span>
</div>
<!-- Discussions Folder -->
<div
class="folder-button group border-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="handleFolderClick('discussions')"
title="Open Discussions folder"
>
<i data-feather="message-square" class="w-10 h-10 text-red-500 group-hover:scale-110 transition-transform duration-200"></i>
<span class="mt-2 text-xs text-center text-gray-700 dark:text-gray-300">Discussions</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUpdated, nextTick, defineProps } from 'vue';
import feather from 'feather-icons';
import filesize from '@/plugins/filesize'; // Assuming filesize plugin is setup
import SVGGPU from '@/assets/gpu.svg'; // Import SVG
// Props definition - Receiving data and functions from parent
const props = defineProps({
// Config object - might contain paths or other relevant static info
config: { type: Object, required: true },
// Direct hardware stats (assuming parent fetches these)
diskUsage: { type: Object, default: null },
ramUsage: { type: Object, default: null },
vramUsage: { type: Object, default: null },
// API Interaction Functions
api_post_req: { type: Function, required: true },
client_id: { type: String, required: true },
// Utility Functions
show_toast: { type: Function, required: true }
});
// Methods
const computedFileSize = (size) => {
if (size === null || size === undefined) return 'N/A';
return filesize(size);
};
const handleFolderClick = async (folderType) => {
const payload = {
client_id: props.client_id,
folder: folderType,
};
try {
const response = await props.api_post_req('open_personal_folder', payload);
if (response.status) {
console.log(`Successfully opened folder: ${folderType}`);
props.show_toast(`Opened ${folderType.replace('-', ' ')} folder`, 4, true);
} else {
console.error(`Failed to open folder: ${folderType}`, response.error);
props.show_toast(`Failed to open folder: ${response.error || 'Unknown error'}`, 4, false);
}
} catch (error) {
console.error('Error calling open_personal_folder endpoint:', error);
props.show_toast(`Error opening folder: ${error.message}`, 4, false);
}
};
// Lifecycle Hooks
onMounted(() => {
nextTick(() => {
feather.replace();
});
});
onUpdated(() => {
nextTick(() => {
feather.replace();
});
});
</script>
<style scoped>
.folder-button {
@apply flex flex-col items-center justify-center p-4 cursor-pointer border-2 border-dashed rounded-lg transition-all duration-200;
min-height: 100px; /* Ensure buttons have a minimum height */
}
.folder-button:hover {
@apply border-solid shadow-sm;
}
.folder-button span {
line-height: 1.2; /* Adjust line height for better text wrapping */
}
</style>