Fix few bugs

parent 5218b437
...@@ -1264,7 +1264,8 @@ class ResponseCache: ...@@ -1264,7 +1264,8 @@ class ResponseCache:
Generate a cache key from request data using semantic deduplication. Generate a cache key from request data using semantic deduplication.
The key is based on: The key is based on:
- model - backend endpoint (provider-agnostic: same endpoint → shared cache)
- model name (provider prefix stripped)
- messages content (hashed for semantic deduplication) - messages content (hashed for semantic deduplication)
- temperature (normalized) - temperature (normalized)
- max_tokens - max_tokens
...@@ -1277,8 +1278,11 @@ class ResponseCache: ...@@ -1277,8 +1278,11 @@ class ResponseCache:
Returns: Returns:
Cache key string Cache key string
""" """
# Extract key components # Extract key components.
model = request_data.get('model', '') # Prefer backend-normalised values (injected by handle_chat_completion) so
# that two providers pointing at the same endpoint+model share cache entries.
model = request_data.get('_backend_model') or request_data.get('model', '')
endpoint = request_data.get('_backend_endpoint', '')
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
temperature = request_data.get('temperature', 1.0) temperature = request_data.get('temperature', 1.0)
max_tokens = request_data.get('max_tokens') max_tokens = request_data.get('max_tokens')
...@@ -1314,6 +1318,7 @@ class ResponseCache: ...@@ -1314,6 +1318,7 @@ class ResponseCache:
# Build key components # Build key components
key_parts = [ key_parts = [
f"endpoint:{endpoint}",
f"model:{model}", f"model:{model}",
f"msgs:{messages_hash}", f"msgs:{messages_hash}",
f"temp:{temperature}" f"temp:{temperature}"
......
...@@ -75,19 +75,29 @@ class ContentClassifier: ...@@ -75,19 +75,29 @@ class ContentClassifier:
try: try:
from transformers import pipeline from transformers import pipeline
self.logger.info(f"Loading NSFW classifier model: {model_name}") self.logger.info(f"Loading NSFW classifier model: {model_name}")
self._nsfw_classifier = pipeline("text-classification", model=model_name) try:
self.logger.info("NSFW classifier loaded successfully") self._nsfw_classifier = pipeline("text-classification", model=model_name, local_files_only=True)
self.logger.info("NSFW classifier loaded from local cache")
except (OSError, EnvironmentError):
self.logger.info("NSFW model not cached, downloading from HuggingFace...")
self._nsfw_classifier = pipeline("text-classification", model=model_name)
self.logger.info("NSFW classifier downloaded and cached")
except Exception as e: except Exception as e:
self.logger.error(f"Failed to load NSFW classifier: {e}") self.logger.error(f"Failed to load NSFW classifier: {e}")
self._nsfw_classifier = None self._nsfw_classifier = None
def _load_privacy_classifier(self, model_name: str): def _load_privacy_classifier(self, model_name: str):
"""Load the privacy classifier model""" """Load the privacy classifier model"""
try: try:
from transformers import pipeline from transformers import pipeline
self.logger.info(f"Loading privacy classifier model: {model_name}") self.logger.info(f"Loading privacy classifier model: {model_name}")
self._privacy_classifier = pipeline("text-classification", model=model_name) try:
self.logger.info("Privacy classifier loaded successfully") self._privacy_classifier = pipeline("text-classification", model=model_name, local_files_only=True)
self.logger.info("Privacy classifier loaded from local cache")
except (OSError, EnvironmentError):
self.logger.info("Privacy model not cached, downloading from HuggingFace...")
self._privacy_classifier = pipeline("text-classification", model=model_name)
self.logger.info("Privacy classifier downloaded and cached")
except Exception as e: except Exception as e:
self.logger.error(f"Failed to load privacy classifier: {e}") self.logger.error(f"Failed to load privacy classifier: {e}")
self._privacy_classifier = None self._privacy_classifier = None
...@@ -163,7 +173,13 @@ class ContentClassifier: ...@@ -163,7 +173,13 @@ class ContentClassifier:
# Default to safe on error # Default to safe on error
return True, f"Error during classification: {str(e)}" return True, f"Error during classification: {str(e)}"
def check_content(self, text: str, check_nsfw: bool = True, check_privacy: bool = True, def reset(self):
"""Unload models from memory so they are re-loaded on next use."""
with self._classifier_lock:
self._nsfw_classifier = None
self._privacy_classifier = None
def check_content(self, text: str, check_nsfw: bool = True, check_privacy: bool = True,
threshold: float = 0.8) -> Tuple[bool, str]: threshold: float = 0.8) -> Tuple[bool, str]:
""" """
Check content for both NSFW and privacy concerns. Check content for both NSFW and privacy concerns.
...@@ -244,8 +260,13 @@ class SemanticClassifier: ...@@ -244,8 +260,13 @@ class SemanticClassifier:
try: try:
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
self.logger.info(f"Loading semantic embedder model: {self._model_name}") self.logger.info(f"Loading semantic embedder model: {self._model_name}")
self._embedder = SentenceTransformer(self._model_name) try:
self.logger.info("Semantic embedder loaded successfully") self._embedder = SentenceTransformer(self._model_name, local_files_only=True)
self.logger.info("Semantic embedder loaded from local cache")
except (OSError, EnvironmentError):
self.logger.info("Embedder not cached, downloading from HuggingFace...")
self._embedder = SentenceTransformer(self._model_name)
self.logger.info("Semantic embedder downloaded and cached")
except Exception as e: except Exception as e:
self.logger.error(f"Failed to load semantic embedder: {e}") self.logger.error(f"Failed to load semantic embedder: {e}")
self._embedder = None self._embedder = None
...@@ -336,6 +357,11 @@ class SemanticClassifier: ...@@ -336,6 +357,11 @@ class SemanticClassifier:
# Fallback to first model # Fallback to first model
return [(list(model_library.keys())[0], 1.0)] if model_library else [] return [(list(model_library.keys())[0], 1.0)] if model_library else []
def reset(self):
"""Unload the embedder from memory so it is re-loaded on next use."""
with self._embedder_lock:
self._embedder = None
def select_best_model( def select_best_model(
self, self,
query: str, query: str,
......
...@@ -194,18 +194,34 @@ class ContextManager: ...@@ -194,18 +194,34 @@ class ContextManager:
device = "cuda" if torch.cuda.is_available() else "cpu" device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info(f"Device: {device}") logger.info(f"Device: {device}")
# Load tokenizer # Load tokenizer - try local cache first, download only on first use
logger.info("Loading tokenizer...") logger.info("Loading tokenizer...")
self._internal_tokenizer = AutoTokenizer.from_pretrained(model_name) try:
logger.info("Tokenizer loaded") self._internal_tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=True)
logger.info("Tokenizer loaded from local cache")
# Load model except (OSError, EnvironmentError):
logger.info("Tokenizer not cached, downloading from HuggingFace...")
self._internal_tokenizer = AutoTokenizer.from_pretrained(model_name)
logger.info("Tokenizer downloaded and cached")
# Load model - try local cache first, download only on first use
logger.info("Loading model...") logger.info("Loading model...")
self._internal_model = AutoModelForCausalLM.from_pretrained( try:
model_name, self._internal_model = AutoModelForCausalLM.from_pretrained(
torch_dtype=torch.float16 if device == "cuda" else torch.float32, model_name,
device_map="auto" if device == "cuda" else None torch_dtype=torch.float16 if device == "cuda" else torch.float32,
) device_map="auto" if device == "cuda" else None,
local_files_only=True
)
logger.info("Model loaded from local cache")
except (OSError, EnvironmentError):
logger.info("Model not cached, downloading from HuggingFace...")
self._internal_model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
device_map="auto" if device == "cuda" else None
)
logger.info("Model downloaded and cached")
if device == "cpu": if device == "cpu":
self._internal_model = self._internal_model.to(device) self._internal_model = self._internal_model.to(device)
......
...@@ -338,6 +338,22 @@ class RequestHandler: ...@@ -338,6 +338,22 @@ class RequestHandler:
provider_config = self.config.get_provider(provider_id) provider_config = self.config.get_provider(provider_id)
logger.info(f"Using global provider config for {provider_id}") logger.info(f"Using global provider config for {provider_id}")
# Inject backend endpoint and normalised model name so the cache key is
# provider-agnostic: two providers sharing the same endpoint+model share
# the same cache entries regardless of their provider_id.
if isinstance(provider_config, dict):
_backend_endpoint = provider_config.get('endpoint', '')
else:
_backend_endpoint = getattr(provider_config, 'endpoint', '')
_raw_model = request_data.get('model', '')
# Models are exposed as "{provider_id}/{model_name}"; strip the prefix.
if _raw_model.startswith(f"{provider_id}/"):
_backend_model = _raw_model[len(provider_id) + 1:]
else:
_backend_model = _raw_model
request_data['_backend_endpoint'] = _backend_endpoint
request_data['_backend_model'] = _backend_model
# Check response cache for non-streaming requests # Check response cache for non-streaming requests
stream = request_data.get('stream', False) stream = request_data.get('stream', False)
if not stream: if not stream:
...@@ -3940,7 +3956,13 @@ class AutoselectHandler: ...@@ -3940,7 +3956,13 @@ class AutoselectHandler:
raise FileNotFoundError("Could not find autoselect.md skill file") raise FileNotFoundError("Could not find autoselect.md skill file")
return self._skill_file_content return self._skill_file_content
def reset_internal_model(self):
"""Unload the internal model from memory so it is re-loaded on next use."""
self._internal_model = None
self._internal_tokenizer = None
self._internal_model_lock = None
def _initialize_internal_model(self): def _initialize_internal_model(self):
"""Initialize the internal HuggingFace model for selection (lazy loading)""" """Initialize the internal HuggingFace model for selection (lazy loading)"""
import logging import logging
...@@ -3990,18 +4012,34 @@ class AutoselectHandler: ...@@ -3990,18 +4012,34 @@ class AutoselectHandler:
device = "cuda" if torch.cuda.is_available() else "cpu" device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info(f"Device: {device}") logger.info(f"Device: {device}")
# Load tokenizer # Load tokenizer - try local cache first, download only on first use
logger.info("Loading tokenizer...") logger.info("Loading tokenizer...")
self._internal_tokenizer = AutoTokenizer.from_pretrained(model_name) try:
logger.info("Tokenizer loaded") self._internal_tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=True)
logger.info("Tokenizer loaded from local cache")
# Load model except (OSError, EnvironmentError):
logger.info("Tokenizer not cached, downloading from HuggingFace...")
self._internal_tokenizer = AutoTokenizer.from_pretrained(model_name)
logger.info("Tokenizer downloaded and cached")
# Load model - try local cache first, download only on first use
logger.info("Loading model...") logger.info("Loading model...")
self._internal_model = AutoModelForCausalLM.from_pretrained( try:
model_name, self._internal_model = AutoModelForCausalLM.from_pretrained(
torch_dtype=torch.float16 if device == "cuda" else torch.float32, model_name,
device_map="auto" if device == "cuda" else None torch_dtype=torch.float16 if device == "cuda" else torch.float32,
) device_map="auto" if device == "cuda" else None,
local_files_only=True
)
logger.info("Model loaded from local cache")
except (OSError, EnvironmentError):
logger.info("Model not cached, downloading from HuggingFace...")
self._internal_model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
device_map="auto" if device == "cuda" else None
)
logger.info("Model downloaded and cached")
if device == "cpu": if device == "cpu":
self._internal_model = self._internal_model.to(device) self._internal_model = self._internal_model.to(device)
......
This diff is collapsed.
...@@ -106,14 +106,17 @@ function renderAutoselectList() { ...@@ -106,14 +106,17 @@ function renderAutoselectList() {
<strong style="font-size: 16px;">${escHtmlAttr(autoselect.model_name || key)}</strong> <strong style="font-size: 16px;">${escHtmlAttr(autoselect.model_name || key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span> <span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span>
</div> </div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button> <div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyAutoselect('${safeKey}')" style="padding: 5px 15px;">Copy</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
</div> </div>
<div id="autoselect-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="autoselect-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div> </div>
`; `;
container.appendChild(autoselectItem); container.appendChild(autoselectItem);
if (isExpanded) { if (isExpanded) {
renderAutoselectDetails(key); renderAutoselectDetails(key);
} }
...@@ -372,6 +375,29 @@ async function saveAutoselectItem(key) { ...@@ -372,6 +375,29 @@ async function saveAutoselectItem(key) {
} }
} }
async function copyAutoselect(sourceKey) {
const newKey = await showPrompt(`Copy "${sourceKey}" — enter new autoselect key:`, `${sourceKey}_copy`, 'autoselect-key', 'Copy Autoselect');
if (!newKey) return;
if (newKey === sourceKey) { showAlert('New key must be different from the source.', 'Invalid Key', '⚠️', 'warn'); return; }
if (autoselectConfig[newKey]) { showAlert('Autoselect key already exists.', 'Duplicate Key', '⚠️', 'warn'); return; }
const cloned = JSON.parse(JSON.stringify(autoselectConfig[sourceKey]));
autoselectConfig[newKey] = cloned;
try {
const result = await apiCall('POST', '/dashboard/api/autoselect', { autoselect_id: newKey, config: cloned });
if (!result.success) {
showAlert('Error copying autoselect: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete autoselectConfig[newKey];
return;
}
} catch (e) {
showAlert('Error copying autoselect: ' + e.message, 'Error', '❌', 'danger');
delete autoselectConfig[newKey];
return;
}
expandedAutoselects.add(newKey);
renderAutoselectList();
}
async function addAutoselect() { async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect'); const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) return; if (!key) return;
......
...@@ -165,6 +165,42 @@ let providerSearchFilter = ''; ...@@ -165,6 +165,42 @@ let providerSearchFilter = '';
// Chunk size: 512KB chunks for maximum compatibility with restrictive proxies // Chunk size: 512KB chunks for maximum compatibility with restrictive proxies
const CHUNK_SIZE = 512 * 1024; const CHUNK_SIZE = 512 * 1024;
function showToast(message, type) {
const alertDiv = document.createElement('div');
alertDiv.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
min-width: 400px;
max-width: calc(100vw - 16px);
padding: 20px 30px;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
${type === 'success' ? 'background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); color: white; border: 2px solid #27ae60;' : ''}
${type === 'danger' ? 'background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white; border: 2px solid #e74c3c;' : ''}
${type === 'warning' ? 'background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%); color: white; border: 2px solid #f39c12;' : ''}
`;
const icon = type === 'success' ? 'fa-check-circle' : type === 'danger' ? 'fa-times-circle' : 'fa-exclamation-triangle';
alertDiv.innerHTML = `<i class="fas ${icon}" style="font-size: 24px; margin-right: 10px; vertical-align: middle;"></i><span style="vertical-align: middle;">${message}</span>`;
if (!document.getElementById('alertAnimations')) {
const style = document.createElement('style');
style.id = 'alertAnimations';
style.textContent = `
@keyframes slideDown { from { opacity:0; transform:translateX(-50%) translateY(-20px); } to { opacity:1; transform:translateX(-50%) translateY(0); } }
@keyframes slideUp { from { opacity:1; transform:translateX(-50%) translateY(0); } to { opacity:0; transform:translateX(-50%) translateY(-20px); } }
`;
document.head.appendChild(style);
}
alertDiv.style.animation = 'slideDown 0.3s ease-out';
document.body.appendChild(alertDiv);
setTimeout(() => {
alertDiv.style.animation = 'slideUp 0.3s ease-out';
setTimeout(() => alertDiv.remove(), 300);
}, 4000);
}
// Generic chunked upload handler for all providers // Generic chunked upload handler for all providers
async function uploadFileChunked(providerKey, fileType, file, configObject) { async function uploadFileChunked(providerKey, fileType, file, configObject) {
if (!file) return; if (!file) return;
...@@ -220,20 +256,23 @@ async function uploadFileChunked(providerKey, fileType, file, configObject) { ...@@ -220,20 +256,23 @@ async function uploadFileChunked(providerKey, fileType, file, configObject) {
// When upload is complete // When upload is complete
if (result.complete) { if (result.complete) {
statusEl.innerHTML = '<div style="color: #4ade80;">File uploaded successfully!</div>'; const savedPath = result.file_path || result.stored_filename || file.name;
statusEl.innerHTML = `<div style="color: #4ade80; display: flex; align-items: center; gap: 6px;"><span style="font-size: 1.1em;">&#10003;</span> Saved: <code style="background: #0f2840; padding: 1px 6px; border-radius: 3px; font-size: 0.9em;">${savedPath}</code></div>`;
showToast(`File uploaded successfully: ${savedPath}`, 'success');
// Update the config with the full file path (not just filename) // Update the config with the full file path (not just filename)
if (!providersData[providerKey][configObject]) { if (!providersData[providerKey][configObject]) {
providersData[providerKey][configObject] = {}; providersData[providerKey][configObject] = {};
} }
// Use file_path from server response (tilde format: ~/.aisbf/...) // Use file_path from server response (tilde format: ~/.aisbf/...)
providersData[providerKey][configObject][fileType] = result.file_path || result.stored_filename; providersData[providerKey][configObject][fileType] = result.file_path || result.stored_filename;
renderProvidersList(); setTimeout(() => renderProvidersList(), 2500);
return; return;
} }
} }
} catch (e) { } catch (e) {
statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`; statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`;
showToast(`Upload failed: ${e.message}`, 'danger');
} }
} }
...@@ -278,12 +317,14 @@ async function uploadClaudeCliFile(providerKey, file) { ...@@ -278,12 +317,14 @@ async function uploadClaudeCliFile(providerKey, file) {
const result = await response.json(); const result = await response.json();
if (!result.success) throw new Error(result.error || 'Upload failed'); if (!result.success) throw new Error(result.error || 'Upload failed');
if (result.complete) { if (result.complete) {
statusEl.innerHTML = '<div style="color: #4ade80;">CLI credentials saved to server!</div>'; statusEl.innerHTML = `<div style="color: #4ade80; display: flex; align-items: center; gap: 6px;"><span style="font-size: 1.1em;">&#10003;</span> CLI credentials saved: <code style="background: #0f2840; padding: 1px 6px; border-radius: 3px; font-size: 0.9em;">${file.name}</code></div>`;
showToast(`CLI credentials saved: ${file.name}`, 'success');
return; return;
} }
} }
} catch (e) { } catch (e) {
statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`; statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`;
showToast(`Upload failed: ${e.message}`, 'danger');
} }
} }
...@@ -1840,6 +1881,10 @@ function updateCodexConfig(key, field, value) { ...@@ -1840,6 +1881,10 @@ function updateCodexConfig(key, field, value) {
function updateProviderType(key, value) { function updateProviderType(key, value) {
providersData[key].type = value; providersData[key].type = value;
const defaultEndpoint = getDefaultEndpoint(value);
if (defaultEndpoint) {
providersData[key].endpoint = defaultEndpoint;
}
// Re-render to update the configuration fields // Re-render to update the configuration fields
renderProvidersList(); renderProvidersList();
} }
......
...@@ -138,14 +138,17 @@ function renderRotationsList() { ...@@ -138,14 +138,17 @@ function renderRotationsList() {
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong> <strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span> <span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span>
</div> </div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button> <div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyRotation('${safeKey}')" style="padding: 5px 15px;">Copy</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
</div> </div>
<div id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div> </div>
`; `;
container.appendChild(rotationItem); container.appendChild(rotationItem);
if (isExpanded) { if (isExpanded) {
renderRotationDetails(key); renderRotationDetails(key);
} }
...@@ -363,6 +366,29 @@ async function saveRotation(key) { ...@@ -363,6 +366,29 @@ async function saveRotation(key) {
} }
} }
async function copyRotation(sourceKey) {
const newKey = await showPrompt(`Copy "${sourceKey}" — enter new rotation key:`, `${sourceKey}_copy`, 'rotation-key', 'Copy Rotation');
if (!newKey) return;
if (newKey === sourceKey) { showAlert('New key must be different from the source.', 'Invalid Key', '⚠️', 'warn'); return; }
if (rotationsConfig.rotations[newKey]) { showAlert('Rotation key already exists.', 'Duplicate Key', '⚠️', 'warn'); return; }
const cloned = JSON.parse(JSON.stringify(rotationsConfig.rotations[sourceKey]));
rotationsConfig.rotations[newKey] = cloned;
try {
const result = await apiCall('POST', '/dashboard/api/rotation', { rotation_id: newKey, config: cloned });
if (!result.success) {
showAlert('Error copying rotation: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete rotationsConfig.rotations[newKey];
return;
}
} catch (e) {
showAlert('Error copying rotation: ' + e.message, 'Error', '❌', 'danger');
delete rotationsConfig.rotations[newKey];
return;
}
expandedRotations.add(newKey);
renderRotationsList();
}
async function addRotation() { async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation'); const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return; if (!key) return;
......
...@@ -151,36 +151,58 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -151,36 +151,58 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="settings-section" id="tab-models"> <div class="settings-section" id="tab-models">
<div class="section-title"><i class="fas fa-brain"></i> Internal Models</div> <div class="section-title"><i class="fas fa-brain"></i> Internal Models</div>
<div class="form-group"> <div class="form-group">
<label for="condensation_model_id">Condensation Model ID</label> <label for="condensation_model_id">Condensation Model ID</label>
<input type="text" id="condensation_model_id" name="condensation_model_id" value="{{ config.internal_model.condensation_model_id }}" required> <div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="condensation_model_id" name="condensation_model_id" value="{{ config.internal_model.condensation_model_id }}" required style="flex:1;">
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('condensation_model_id').value, 'condensation', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: #666; display: block; margin-top: 5px;">Used when condensation model is set to "internal"</small> <small style="color: #666; display: block; margin-top: 5px;">Used when condensation model is set to "internal"</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="autoselect_model_id">Autoselect Model ID</label> <label for="autoselect_model_id">Autoselect Model ID</label>
<input type="text" id="autoselect_model_id" name="autoselect_model_id" value="{{ config.internal_model.autoselect_model_id }}" required> <div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="autoselect_model_id" name="autoselect_model_id" value="{{ config.internal_model.autoselect_model_id }}" required style="flex:1;">
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('autoselect_model_id').value, 'autoselect', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: #666; display: block; margin-top: 5px;">Used when autoselect selection_model is set to "internal"</small> <small style="color: #666; display: block; margin-top: 5px;">Used when autoselect selection_model is set to "internal"</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="nsfw_classifier">NSFW Classifier Model ID</label> <label for="nsfw_classifier">NSFW Classifier Model ID</label>
<input type="text" id="nsfw_classifier" name="nsfw_classifier" value="{{ config.internal_model.nsfw_classifier or 'michelleli99/NSFW_text_classifier' }}" required> <div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="nsfw_classifier" name="nsfw_classifier" value="{{ config.internal_model.nsfw_classifier or 'michelleli99/NSFW_text_classifier' }}" required style="flex:1;">
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('nsfw_classifier').value, 'nsfw', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: #666; display: block; margin-top: 5px;">Model used for NSFW content detection</small> <small style="color: #666; display: block; margin-top: 5px;">Model used for NSFW content detection</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="privacy_classifier">Privacy Classifier Model ID</label> <label for="privacy_classifier">Privacy Classifier Model ID</label>
<input type="text" id="privacy_classifier" name="privacy_classifier" value="{{ config.internal_model.privacy_classifier or 'iiiorg/piiranha-v1-detect-personal-information' }}" required> <div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="privacy_classifier" name="privacy_classifier" value="{{ config.internal_model.privacy_classifier or 'iiiorg/piiranha-v1-detect-personal-information' }}" required style="flex:1;">
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('privacy_classifier').value, 'privacy', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: #666; display: block; margin-top: 5px;">Model used for privacy-sensitive information detection</small> <small style="color: #666; display: block; margin-top: 5px;">Model used for privacy-sensitive information detection</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="semantic_vectorization">Semantic Vectorization Model ID</label> <label for="semantic_vectorization">Semantic Vectorization Model ID</label>
<input type="text" id="semantic_vectorization" name="semantic_vectorization" value="{{ config.internal_model.semantic_vectorization or 'sentence-transformers/all-MiniLM-L6-v2' }}" required> <div style="display:flex; gap:8px; align-items:center;">
<input type="text" id="semantic_vectorization" name="semantic_vectorization" value="{{ config.internal_model.semantic_vectorization or 'sentence-transformers/all-MiniLM-L6-v2' }}" required style="flex:1;">
<button type="button" class="btn btn-secondary" style="white-space:nowrap; padding:6px 12px; font-size:.85em;" onclick="clearModelCache(document.getElementById('semantic_vectorization').value, 'semantic', this)"><i class="fas fa-trash-alt"></i> Clear Cache</button>
</div>
<small style="color: #666; display: block; margin-top: 5px;">Model used for semantic embedding and vectorization</small> <small style="color: #666; display: block; margin-top: 5px;">Model used for semantic embedding and vectorization</small>
</div> </div>
<div style="margin-top:20px; padding-top:16px; border-top:1px solid #0f3460;">
<button type="button" class="btn" style="background:#b71c1c; border-color:#b71c1c;" onclick="clearAllModelCaches(this)">
<i class="fas fa-trash-alt"></i> Clear All Local Model Caches
</button>
<small style="color:#666; display:block; margin-top:8px;">Removes all locally cached model files and unloads them from memory. Models will be re-downloaded from HuggingFace on next use.</small>
</div>
</div><!-- /tab-models --> </div><!-- /tab-models -->
<div class="settings-section" id="tab-database"> <div class="settings-section" id="tab-database">
...@@ -1232,6 +1254,75 @@ async function refreshCacheStats() { ...@@ -1232,6 +1254,75 @@ async function refreshCacheStats() {
} }
} }
async function clearModelCache(modelId, modelType, btn) {
if (!modelId) {
showAlert('Model ID is empty.', 'Error', '⚠️', 'warn');
return;
}
const ok = await showDangerConfirm(
`Delete local cache for <strong>${modelId}</strong>?<br><br>The model will be re-downloaded from HuggingFace on next use.`,
'Clear Model Cache'
);
if (!ok) return;
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Clearing…';
try {
const response = await fetch('{{ url_for(request, "/dashboard/local-models/clear-cache") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model_id: modelId, model_type: modelType})
});
const data = await response.json();
if (data.success) {
showAlert(`Cache cleared for <strong>${modelId}</strong>. It will be re-downloaded on next use.`, 'Done', '✅', 'info');
} else {
const errs = (data.errors || []).join('<br>');
showAlert('Cache clear finished with errors:<br>' + errs, 'Warning', '⚠️', 'warn');
}
} catch (err) {
showAlert('Error: ' + err.message, 'Error', '❌', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = orig;
}
}
async function clearAllModelCaches(btn) {
const ok = await showDangerConfirm(
'Delete the local cache for <strong>all</strong> configured models?<br><br>All models will be re-downloaded from HuggingFace on next use.',
'Clear All Model Caches'
);
if (!ok) return;
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Clearing…';
try {
const response = await fetch('{{ url_for(request, "/dashboard/local-models/clear-cache") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({})
});
const data = await response.json();
if (data.success) {
const count = (data.cleared || []).length;
showAlert(`Cleared ${count} model cache(s). They will be re-downloaded on next use.`, 'Done', '✅', 'info');
} else {
const errs = (data.errors || []).join('<br>');
showAlert('Cache clear finished with errors:<br>' + errs, 'Warning', '⚠️', 'warn');
}
} catch (err) {
showAlert('Error: ' + err.message, 'Error', '❌', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = orig;
}
}
async function clearResponseCache() { async function clearResponseCache() {
const ok = await showDangerConfirm('Are you sure you want to clear the response cache? This will remove all cached responses.', 'Clear Cache'); const ok = await showDangerConfirm('Are you sure you want to clear the response cache? This will remove all cached responses.', 'Clear Cache');
if (!ok) return; if (!ok) return;
......
...@@ -118,15 +118,18 @@ function renderAutoselectList() { ...@@ -118,15 +118,18 @@ function renderAutoselectList() {
<strong style="font-size: 16px;">${escHtml(autoselect.model_name || key)}</strong> <strong style="font-size: 16px;">${escHtml(autoselect.model_name || key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span> <span style="color: #a0a0a0; font-size: 14px;">(${modelCount} available model${modelCount !== 1 ? 's' : ''})</span>
</div> </div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button> <div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyAutoselect('${safeKey}')" style="padding: 5px 15px;">Copy</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeAutoselect('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
</div> </div>
<div id="autoselect-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="autoselect-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
<!-- Details will be rendered here --> <!-- Details will be rendered here -->
</div> </div>
`; `;
container.appendChild(autoselectItem); container.appendChild(autoselectItem);
if (isExpanded) { if (isExpanded) {
renderAutoselectDetails(key); renderAutoselectDetails(key);
} }
...@@ -378,6 +381,29 @@ async function saveAutoselectItem(key) { ...@@ -378,6 +381,29 @@ async function saveAutoselectItem(key) {
} }
} }
async function copyAutoselect(sourceKey) {
const newKey = await showPrompt(`Copy "${sourceKey}" — enter new autoselect key:`, `${sourceKey}_copy`, 'autoselect-key', 'Copy Autoselect');
if (!newKey) return;
if (newKey === sourceKey) { showAlert('New key must be different from the source.', 'Invalid Key', '⚠️', 'warn'); return; }
if (autoselectConfig[newKey]) { showAlert('Autoselect key already exists.', 'Duplicate Key', '⚠️', 'warn'); return; }
const cloned = JSON.parse(JSON.stringify(autoselectConfig[sourceKey]));
autoselectConfig[newKey] = cloned;
try {
const result = await apiCall('POST', '/dashboard/api/autoselect', { autoselect_id: newKey, config: cloned });
if (!result.success) {
showAlert('Error copying autoselect: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete autoselectConfig[newKey];
return;
}
} catch (e) {
showAlert('Error copying autoselect: ' + e.message, 'Error', '❌', 'danger');
delete autoselectConfig[newKey];
return;
}
expandedAutoselects.add(newKey);
renderAutoselectList();
}
async function addAutoselect() { async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect'); const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) return; if (!key) return;
......
...@@ -217,44 +217,25 @@ function showToast(message, type) { ...@@ -217,44 +217,25 @@ function showToast(message, type) {
min-width: 400px; min-width: 400px;
max-width: calc(100vw - 16px); max-width: calc(100vw - 16px);
padding: 20px 30px; padding: 20px 30px;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
${type === 'success' ? 'background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); color: white; border: 2px solid #27ae60;' : ''} ${type === 'success' ? 'background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); color: white; border: 2px solid #27ae60;' : ''}
${type === 'danger' ? 'background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white; border: 2px solid #e74c3c;' : ''} ${type === 'danger' ? 'background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); color: white; border: 2px solid #e74c3c;' : ''}
${type === 'warning' ? 'background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%); color: white; border: 2px solid #f39c12;' : ''} ${type === 'warning' ? 'background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%); color: white; border: 2px solid #f39c12;' : ''}
`; `;
const icon = type === 'success' ? 'fa-check-circle' : type === 'danger' ? 'fa-times-circle' : 'fa-exclamation-triangle'; const icon = type === 'success' ? 'fa-check-circle' : type === 'danger' ? 'fa-times-circle' : 'fa-exclamation-triangle';
alertDiv.innerHTML = `<i class="fas ${icon}" style="font-size: 24px; margin-right: 10px; vertical-align: middle;"></i><span style="vertical-align: middle;">${message}</span>`; alertDiv.innerHTML = `<i class="fas ${icon}" style="font-size: 24px; margin-right: 10px; vertical-align: middle;"></i><span style="vertical-align: middle;">${message}</span>`;
if (!document.getElementById('alertAnimations')) { if (!document.getElementById('alertAnimations')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'alertAnimations'; style.id = 'alertAnimations';
style.textContent = ` style.textContent = `
@keyframes slideDown { @keyframes slideDown { from { opacity:0; transform:translateX(-50%) translateY(-20px); } to { opacity:1; transform:translateX(-50%) translateY(0); } }
from { @keyframes slideUp { from { opacity:1; transform:translateX(-50%) translateY(0); } to { opacity:0; transform:translateX(-50%) translateY(-20px); } }
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
} }
alertDiv.style.animation = 'slideDown 0.3s ease-out';
document.body.appendChild(alertDiv); document.body.appendChild(alertDiv);
setTimeout(() => { setTimeout(() => {
alertDiv.style.animation = 'slideUp 0.3s ease-out'; alertDiv.style.animation = 'slideUp 0.3s ease-out';
setTimeout(() => alertDiv.remove(), 300); setTimeout(() => alertDiv.remove(), 300);
...@@ -342,20 +323,23 @@ async function uploadFileChunked(providerKey, fileType, file, configObject) { ...@@ -342,20 +323,23 @@ async function uploadFileChunked(providerKey, fileType, file, configObject) {
// When upload is complete // When upload is complete
if (result.complete) { if (result.complete) {
statusEl.innerHTML = '<div style="color: #4ade80;">File uploaded successfully!</div>'; const savedPath = result.file_path || result.stored_filename || file.name;
statusEl.innerHTML = `<div style="color: #4ade80; display: flex; align-items: center; gap: 6px;"><span style="font-size: 1.1em;">&#10003;</span> Saved: <code style="background: #0f2840; padding: 1px 6px; border-radius: 3px; font-size: 0.9em;">${savedPath}</code></div>`;
showToast(`File uploaded successfully: ${savedPath}`, 'success');
// Update the config with the full file path (not just filename) // Update the config with the full file path (not just filename)
if (!providersData[providerKey][configObject]) { if (!providersData[providerKey][configObject]) {
providersData[providerKey][configObject] = {}; providersData[providerKey][configObject] = {};
} }
// Use file_path from server response (tilde format: ~/.aisbf/...) // Use file_path from server response (tilde format: ~/.aisbf/...)
providersData[providerKey][configObject][fileType] = result.file_path || result.stored_filename; providersData[providerKey][configObject][fileType] = result.file_path || result.stored_filename;
renderProvidersList(); setTimeout(() => renderProvidersList(), 2500);
return; return;
} }
} }
} catch (e) { } catch (e) {
statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`; statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`;
showToast(`Upload failed: ${e.message}`, 'danger');
} }
} }
...@@ -400,12 +384,14 @@ async function uploadClaudeCliFile(providerKey, file) { ...@@ -400,12 +384,14 @@ async function uploadClaudeCliFile(providerKey, file) {
const result = await response.json(); const result = await response.json();
if (!result.success) throw new Error(result.error || 'Upload failed'); if (!result.success) throw new Error(result.error || 'Upload failed');
if (result.complete) { if (result.complete) {
statusEl.innerHTML = '<div style="color: #4ade80;">CLI credentials uploaded and saved!</div>'; statusEl.innerHTML = `<div style="color: #4ade80; display: flex; align-items: center; gap: 6px;"><span style="font-size: 1.1em;">&#10003;</span> CLI credentials saved: <code style="background: #0f2840; padding: 1px 6px; border-radius: 3px; font-size: 0.9em;">${file.name}</code></div>`;
showToast(`CLI credentials saved: ${file.name}`, 'success');
return; return;
} }
} }
} catch (e) { } catch (e) {
statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`; statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`;
showToast(`Upload failed: ${e.message}`, 'danger');
} }
} }
...@@ -1927,6 +1913,10 @@ function updateCodexConfig(key, field, value) { ...@@ -1927,6 +1913,10 @@ function updateCodexConfig(key, field, value) {
function updateProviderType(key, value) { function updateProviderType(key, value) {
providersData[key].type = value; providersData[key].type = value;
const defaultEndpoint = getDefaultEndpoint(value);
if (defaultEndpoint) {
providersData[key].endpoint = defaultEndpoint;
}
// Re-render to update the configuration fields // Re-render to update the configuration fields
renderProvidersList(); renderProvidersList();
} }
......
...@@ -127,14 +127,17 @@ function renderRotationsList() { ...@@ -127,14 +127,17 @@ function renderRotationsList() {
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong> <strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span> <span style="color: #a0a0a0; font-size: 14px;">(${providerCount} provider${providerCount !== 1 ? 's' : ''})</span>
</div> </div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button> <div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); copyRotation('${safeKey}')" style="padding: 5px 15px;">Copy</button>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeRotation('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">Remove</button>
</div>
</div> </div>
<div id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;"> <div id="rotation-details-${escHtmlAttr(key)}" style="display: ${isExpanded ? 'block' : 'none'}; padding: 20px; border-top: 1px solid #0f3460; background: #16213e;">
</div> </div>
`; `;
container.appendChild(rotationItem); container.appendChild(rotationItem);
if (isExpanded) { if (isExpanded) {
renderRotationDetails(key); renderRotationDetails(key);
} }
...@@ -352,6 +355,29 @@ async function saveRotation(key) { ...@@ -352,6 +355,29 @@ async function saveRotation(key) {
} }
} }
async function copyRotation(sourceKey) {
const newKey = await showPrompt(`Copy "${sourceKey}" — enter new rotation key:`, `${sourceKey}_copy`, 'rotation-key', 'Copy Rotation');
if (!newKey) return;
if (newKey === sourceKey) { showAlert('New key must be different from the source.', 'Invalid Key', '⚠️', 'warn'); return; }
if (rotationsConfig.rotations[newKey]) { showAlert('Rotation key already exists.', 'Duplicate Key', '⚠️', 'warn'); return; }
const cloned = JSON.parse(JSON.stringify(rotationsConfig.rotations[sourceKey]));
rotationsConfig.rotations[newKey] = cloned;
try {
const result = await apiCall('POST', '/dashboard/api/rotation', { rotation_id: newKey, config: cloned });
if (!result.success) {
showAlert('Error copying rotation: ' + (result.error || 'Unknown'), 'Error', '❌', 'danger');
delete rotationsConfig.rotations[newKey];
return;
}
} catch (e) {
showAlert('Error copying rotation: ' + e.message, 'Error', '❌', 'danger');
delete rotationsConfig.rotations[newKey];
return;
}
expandedRotations.add(newKey);
renderRotationsList();
}
async function addRotation() { async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation'); const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return; if (!key) return;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment