mirror of
https://github.com/ParisNeo/lollms-webui.git
synced 2025-04-08 03:14:17 +00:00
New version
This commit is contained in:
parent
8f8dd8f39b
commit
2e1b921fc5
49
web/src/components/SettingsSidebar.vue
Normal file
49
web/src/components/SettingsSidebar.vue
Normal 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>
|
42
web/src/components/ToggleSwitch.vue
Normal file
42
web/src/components/ToggleSwitch.vue
Normal 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>
|
394
web/src/views/settings_components/BindingZooSettings.vue
Normal file
394
web/src/views/settings_components/BindingZooSettings.vue
Normal 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>
|
714
web/src/views/settings_components/DataManagementSettings.vue
Normal file
714
web/src/views/settings_components/DataManagementSettings.vue
Normal 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>
|
570
web/src/views/settings_components/FunctionCallsZooSettings.vue
Normal file
570
web/src/views/settings_components/FunctionCallsZooSettings.vue
Normal 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>
|
173
web/src/views/settings_components/InternetSettings.vue
Normal file
173
web/src/views/settings_components/InternetSettings.vue
Normal 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>
|
285
web/src/views/settings_components/MainConfigSettings.vue
Normal file
285
web/src/views/settings_components/MainConfigSettings.vue
Normal 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>
|
217
web/src/views/settings_components/ModelConfigSettings.vue
Normal file
217
web/src/views/settings_components/ModelConfigSettings.vue
Normal 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>
|
818
web/src/views/settings_components/ModelsZooSettings.vue
Normal file
818
web/src/views/settings_components/ModelsZooSettings.vue
Normal 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>
|
710
web/src/views/settings_components/PersonalitiesZooSettings.vue
Normal file
710
web/src/views/settings_components/PersonalitiesZooSettings.vue
Normal 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>
|
473
web/src/views/settings_components/ServicesZooSettings.vue
Normal file
473
web/src/views/settings_components/ServicesZooSettings.vue
Normal 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>
|
124
web/src/views/settings_components/SmartRoutingSettings.vue
Normal file
124
web/src/views/settings_components/SmartRoutingSettings.vue
Normal 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>
|
230
web/src/views/settings_components/SystemStatusSettings.vue
Normal file
230
web/src/views/settings_components/SystemStatusSettings.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user