Compare commits

...

6 Commits

Author SHA1 Message Date
201f2e3df5 feat(ui): add model selection UI to LLM Settings tab
- Three dropdowns for Regular Miku, Evil Miku, Japanese Mode models
- GPU availability badges (Both GPUs / NVIDIA Only / AMD Only)
- Refresh Models + Refresh Status buttons
- Load models on tab switch with defensive checks
- Bump cache-busting version for all JS files
- Remove redundant Current Status section
2026-05-20 13:55:35 +03:00
b017a0ec04 feat(api): register models_selector routes
- Import and include models_selector router in FastAPI app
2026-05-20 13:55:29 +03:00
6bf9a30c33 feat(routes): sync model globals via config API, fix log message
- Add models.text, models.evil, models.japanese to config/set globals sync
- Fix language toggle log to show actual model name instead of hardcoded string
2026-05-20 13:55:22 +03:00
8e5260561a feat(config): persist model selections via config_manager
- Add models.text, models.evil, models.japanese to restore_runtime_settings
- Add model keys to reset_to_defaults with CONFIG defaults
- Include model info in runtime_state for API visibility
2026-05-20 13:55:11 +03:00
b4737c1ae1 fix(cat): use configured models instead of hardcoded strings
- switch_to_evil_personality now reads EVIL_TEXT_MODEL from globals
- switch_to_normal_personality now reads TEXT_MODEL from globals
- Removes desync risk when user changes models via Web UI
2026-05-20 13:55:05 +03:00
ae4e40f2d7 feat(models): add model selection API endpoints
- GET /models/available: query both llama-swap instances for model lists
- POST /models/select: set per-persona model (regular/evil/japanese) with persistence
- GET /models/status: return current per-persona model assignments
- Fall back to known model list when containers are unreachable
2026-05-20 13:54:59 +03:00
9 changed files with 465 additions and 31 deletions

View File

@@ -102,6 +102,7 @@ from routes.logging_config import router as logging_config_router
from routes.voice import router as voice_router
from routes.memory import router as memory_router
from routes.activities import router as activities_router
from routes.models_selector import router as models_selector_router
app.include_router(core_router)
app.include_router(mood_router)
@@ -123,6 +124,7 @@ app.include_router(logging_config_router)
app.include_router(voice_router)
app.include_router(memory_router)
app.include_router(activities_router)
app.include_router(models_selector_router)

View File

@@ -112,11 +112,14 @@ class ConfigManager:
# Map: config_runtime.yaml key path -> (globals attribute, converter)
_SETTINGS_MAP = {
"discord.language_mode": ("LANGUAGE_MODE", str),
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
"discord.language_mode": ("LANGUAGE_MODE", str),
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
"models.text": ("TEXT_MODEL", str),
"models.evil": ("EVIL_TEXT_MODEL", str),
"models.japanese": ("JAPANESE_TEXT_MODEL", str),
}
restored = []
@@ -253,6 +256,9 @@ class ConfigManager:
"voice.debug_mode": ("VOICE_DEBUG_MODE", CONFIG.voice.debug_mode),
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", CONFIG.cheshire_cat.enabled),
"gpu.prefer_amd": ("PREFER_AMD_GPU", CONFIG.gpu.prefer_amd),
"models.text": ("TEXT_MODEL", CONFIG.models.text),
"models.evil": ("EVIL_TEXT_MODEL", CONFIG.models.evil),
"models.japanese": ("JAPANESE_TEXT_MODEL", CONFIG.models.japanese),
}
reset_items = []
@@ -308,6 +314,9 @@ class ConfigManager:
"bipolar_mode": getattr(g, "BIPOLAR_MODE", False),
"language_mode": getattr(g, "LANGUAGE_MODE", "english"),
"current_gpu": self._current_gpu,
"text_model": getattr(g, "TEXT_MODEL", "llama3.1"),
"evil_text_model": getattr(g, "EVIL_TEXT_MODEL", "darkidol"),
"japanese_text_model": getattr(g, "JAPANESE_TEXT_MODEL", "swallow"),
}
def get_state(self, key: str, default: Any = None) -> Any:

View File

@@ -93,6 +93,9 @@ async def set_config_value(request: Request):
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
"models.text": ("TEXT_MODEL", str),
"models.evil": ("EVIL_TEXT_MODEL", str),
"models.japanese": ("JAPANESE_TEXT_MODEL", str),
}
if key_path in _GLOBALS_SYNC:

View File

@@ -27,7 +27,7 @@ def toggle_language_mode():
globals.LANGUAGE_MODE = "japanese"
new_mode = "japanese"
model_used = globals.JAPANESE_TEXT_MODEL
logger.info("Switched to Japanese mode (using Llama 3.1 Swallow)")
logger.info(f"Switched to Japanese mode (using {model_used})")
else:
globals.LANGUAGE_MODE = "english"
new_mode = "english"

View File

@@ -0,0 +1,161 @@
"""Model selection routes: query available models and set per-persona models."""
import aiohttp
import asyncio
import globals
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
# Known model names from llama-swap configs (fallback if API query fails)
KNOWN_MODELS = [
"llama3.1",
"darkidol",
"swallow",
"vision",
"rocinante",
"qwen3.5",
]
# Which GPU each model is available on
MODEL_GPU_MAP = {
"llama3.1": {"nvidia", "amd"},
"darkidol": {"nvidia", "amd"},
"swallow": {"nvidia", "amd"},
"vision": {"nvidia"},
"rocinante": {"amd"},
"qwen3.5": {"amd"},
}
async def _query_llama_swap_models(url: str, timeout: int = 10) -> list:
"""Query a llama-swap instance for its available models via /v1/models."""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{url}/v1/models",
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status == 200:
data = await resp.json()
# OpenAI-compatible format: { data: [{ id: "model_name", ... }] }
return [m["id"] for m in data.get("data", []) if "id" in m]
else:
logger.warning(f"llama-swap models query failed ({resp.status}) for {url}")
return []
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
logger.warning(f"llama-swap unreachable at {url}: {e}")
return []
except Exception as e:
logger.warning(f"Unexpected error querying {url}: {e}")
return []
@router.get("/models/available")
async def get_available_models():
"""
Query both NVIDIA and AMD llama-swap instances for available models.
Returns model lists per GPU, their intersection, and all unique models.
Falls back to known model list if containers are unreachable.
"""
nvidia_models = await _query_llama_swap_models(globals.LLAMA_URL)
amd_models = await _query_llama_swap_models(globals.LLAMA_AMD_URL)
# If both failed, use the known model list from configs
if not nvidia_models and not amd_models:
logger.info("Both llama-swap instances unreachable, using known model list")
nvidia_set = {m for m, gpus in MODEL_GPU_MAP.items() if "nvidia" in gpus}
amd_set = {m for m, gpus in MODEL_GPU_MAP.items() if "amd" in gpus}
return {
"success": True,
"nvidia": sorted(nvidia_set),
"amd": sorted(amd_set),
"intersection": sorted(nvidia_set & amd_set),
"all": sorted(nvidia_set | amd_set),
"gpu_map": MODEL_GPU_MAP,
"source": "fallback",
}
nvidia_set = set(nvidia_models)
amd_set = set(amd_models)
return {
"success": True,
"nvidia": sorted(nvidia_set),
"amd": sorted(amd_set),
"intersection": sorted(nvidia_set & amd_set),
"all": sorted(nvidia_set | amd_set),
"gpu_map": MODEL_GPU_MAP,
"source": "live",
}
@router.post("/models/select")
async def select_model(body: dict):
"""
Set the model for a specific persona.
Body: {
"persona": "regular" | "evil" | "japanese",
"model": "model_name"
}
Persists the selection so it survives bot restarts.
"""
persona = body.get("persona", "").strip().lower()
model = body.get("model", "").strip()
valid_personas = {"regular", "evil", "japanese"}
if persona not in valid_personas:
return JSONResponse(
status_code=400,
content={"success": False, "error": f"Invalid persona '{persona}'. Must be one of: {', '.join(valid_personas)}"}
)
if not model:
return JSONResponse(
status_code=400,
content={"success": False, "error": "model is required"}
)
# Map persona to globals attribute and config key
PERSONA_MAP = {
"regular": ("TEXT_MODEL", "models.text"),
"evil": ("EVIL_TEXT_MODEL", "models.evil"),
"japanese": ("JAPANESE_TEXT_MODEL", "models.japanese"),
}
attr_name, config_key = PERSONA_MAP[persona]
# Set the global
setattr(globals, attr_name, model)
logger.info(f"Model selection: {persona}{model} (globals.{attr_name})")
# Persist via config manager
try:
from config_manager import config_manager
config_manager.set(config_key, model, persist=True)
except Exception as e:
logger.warning(f"Failed to persist model selection: {e}")
return {
"success": True,
"persona": persona,
"model": model,
"message": f"{persona.capitalize()} model set to '{model}'",
}
@router.get("/models/status")
async def get_model_status():
"""Return the current per-persona model assignments."""
return {
"success": True,
"regular": getattr(globals, "TEXT_MODEL", "llama3.1"),
"evil": getattr(globals, "EVIL_TEXT_MODEL", "darkidol"),
"japanese": getattr(globals, "JAPANESE_TEXT_MODEL", "swallow"),
}

View File

@@ -663,15 +663,55 @@
</div>
</div>
<!-- Language Mode Status Section -->
<div style="margin-bottom: 1.5rem; padding: 1rem; background: #2a2a2a; border-radius: 4px;">
<h4 style="margin-top: 0;">📊 Current Status</h4>
<div id="language-status-display" style="background: #1a1a1a; padding: 1rem; border-radius: 4px; font-family: monospace; font-size: 0.9rem;">
<p style="margin: 0.5rem 0;"><strong>Language Mode:</strong> <span id="status-language">English</span></p>
<p style="margin: 0.5rem 0;"><strong>Active Model:</strong> <span id="status-model">llama3.1</span></p>
<p style="margin: 0.5rem 0;"><strong>Available Languages:</strong> English, 日本語 (Japanese)</p>
<!-- Model Selection Section -->
<div style="margin-bottom: 1.5rem; padding: 1rem; background: #2a2a2a; border-radius: 4px; border: 2px solid #7c4dff;">
<h4 style="margin-top: 0; color: #b388ff;">🎛️ Model Selection</h4>
<p style="margin: 0.5rem 0; color: #aaa;">Choose which model each persona uses. Changes take effect immediately and persist across bot restarts.</p>
<div style="margin: 1rem 0;">
<div id="model-selection-loading" style="color: #aaa;">Loading available models...</div>
<div id="model-selection-controls" style="display: none;">
<!-- Regular Miku -->
<div style="margin-bottom: 1rem; padding: 0.8rem; background: #1a1a1a; border-radius: 4px; border-left: 3px solid #69f0ae;">
<label for="model-regular" style="font-weight: bold; color: #69f0ae;">🎤 Regular Miku</label>
<div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<select id="model-regular" style="flex: 1; min-width: 200px; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;"></select>
<span id="model-regular-badge" style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px;"></span>
<button onclick="selectModel('regular')" style="background: #69f0ae; color: #000; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply</button>
</div>
</div>
<!-- Evil Miku -->
<div style="margin-bottom: 1rem; padding: 0.8rem; background: #1a1a1a; border-radius: 4px; border-left: 3px solid #ff5252;">
<label for="model-evil" style="font-weight: bold; color: #ff5252;">😈 Evil Miku</label>
<div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<select id="model-evil" style="flex: 1; min-width: 200px; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;"></select>
<span id="model-evil-badge" style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px;"></span>
<button onclick="selectModel('evil')" style="background: #ff5252; color: #fff; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply</button>
</div>
</div>
<!-- Japanese Mode -->
<div style="margin-bottom: 1rem; padding: 0.8rem; background: #1a1a1a; border-radius: 4px; border-left: 3px solid #40c4ff;">
<label for="model-japanese" style="font-weight: bold; color: #40c4ff;">🗾 Japanese Mode</label>
<div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<select id="model-japanese" style="flex: 1; min-width: 200px; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;"></select>
<span id="model-japanese-badge" style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px;"></span>
<button onclick="selectModel('japanese')" style="background: #40c4ff; color: #000; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply</button>
</div>
</div>
</div>
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button onclick="loadAvailableModels()" style="background: #7c4dff; color: #fff; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">🔄 Refresh Models</button>
<button onclick="refreshModelStatus()" style="background: #333; color: #fff; padding: 0.5rem 1rem; border: 1px solid #555; border-radius: 4px; cursor: pointer;">📊 Refresh Status</button>
</div>
</div>
<div id="model-selection-info" style="margin-top: 0.5rem; padding: 0.5rem; background: #1a1a1a; border-radius: 4px; font-size: 0.8rem; color: #888; display: none;">
<span id="model-selection-info-text"></span>
</div>
<button onclick="refreshLanguageStatus()" style="margin-top: 1rem;">🔄 Refresh Status</button>
</div>
<!-- Information Section -->
@@ -1375,15 +1415,15 @@
</div>
</div>
<script src="/static/js/core.js?v=20260502"></script>
<script src="/static/js/servers.js?v=20260502"></script>
<script src="/static/js/modes.js?v=20260502"></script>
<script src="/static/js/actions.js?v=20260502"></script>
<script src="/static/js/image-gen.js?v=20260502"></script>
<script src="/static/js/status.js?v=20260502"></script>
<script src="/static/js/dm.js?v=20260502"></script>
<script src="/static/js/chat.js?v=20260502"></script>
<script src="/static/js/memories.js?v=20260502"></script>
<script src="/static/js/profile.js?v=20260502"></script>
<script src="/static/js/core.js?v=20260520"></script>
<script src="/static/js/servers.js?v=20260520"></script>
<script src="/static/js/modes.js?v=20260520"></script>
<script src="/static/js/actions.js?v=20260520"></script>
<script src="/static/js/image-gen.js?v=20260520"></script>
<script src="/static/js/status.js?v=20260520"></script>
<script src="/static/js/dm.js?v=20260520"></script>
<script src="/static/js/chat.js?v=20260520"></script>
<script src="/static/js/memories.js?v=20260520"></script>
<script src="/static/js/profile.js?v=20260520"></script>
</body>
</html>

View File

@@ -168,6 +168,13 @@ function switchTab(tabId) {
showTabLoading('tab6');
loadAutonomousStats().finally(() => hideTabLoading('tab6'));
}
if (tabId === 'tab4') {
if (typeof loadAvailableModels === 'function') {
loadAvailableModels();
} else {
console.warn('loadAvailableModels not available yet (servers.js may not be loaded)');
}
}
if (tabId === 'tab9') {
console.log('🧠 Refreshing memory stats for Memories tab');
showTabLoading('tab9');

View File

@@ -745,3 +745,213 @@ async function toggleLanguageMode() {
showNotification('Failed to toggle language mode', 'error');
}
}
// ===== Model Selection Functions =====
let availableModelsData = null;
async function loadAvailableModels() {
console.log('📋 loadAvailableModels() called');
try {
const loadingEl = document.getElementById('model-selection-loading');
const controlsEl = document.getElementById('model-selection-controls');
const infoEl = document.getElementById('model-selection-info');
const infoTextEl = document.getElementById('model-selection-info-text');
if (loadingEl) loadingEl.style.display = 'block';
if (controlsEl) controlsEl.style.display = 'none';
if (infoEl) infoEl.style.display = 'none';
console.log('📋 Fetching /models/available...');
const result = await apiCall('/models/available');
console.log('📋 /models/available response:', result);
availableModelsData = result;
if (loadingEl) loadingEl.style.display = 'none';
if (controlsEl) controlsEl.style.display = 'block';
if (!result.success) {
showNotification('Failed to load models: ' + (result.error || 'Unknown error'), 'error');
return;
}
// Populate dropdowns
const allModels = result.all || [];
const gpuMap = result.gpu_map || {};
console.log('📋 Populating dropdowns with models:', allModels);
const personas = [
{ id: 'regular', selectId: 'model-regular', badgeId: 'model-regular-badge' },
{ id: 'evil', selectId: 'model-evil', badgeId: 'model-evil-badge' },
{ id: 'japanese', selectId: 'model-japanese', badgeId: 'model-japanese-badge' },
];
for (const p of personas) {
const select = document.getElementById(p.selectId);
if (!select) continue;
// Save current selection
const currentVal = select.value;
select.innerHTML = '';
for (const model of allModels) {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
select.appendChild(option);
}
// Restore selection if still valid
if (currentVal && allModels.includes(currentVal)) {
select.value = currentVal;
}
}
// Update badges for current selections
updateModelBadges(allModels, gpuMap);
// Show info about source
if (infoEl && infoTextEl) {
if (result.source === 'fallback') {
infoTextEl.textContent = '⚠️ Could not reach llama-swap containers. Showing known models from config.';
infoEl.style.display = 'block';
} else {
infoEl.style.display = 'none';
}
}
// Load current status to sync dropdowns
await refreshModelStatus();
console.log('Available models loaded:', result);
} catch (error) {
console.error('Failed to load available models:', error);
const loadingEl = document.getElementById('model-selection-loading');
if (loadingEl) loadingEl.textContent = '❌ Failed to load models. Click "Refresh Models" to retry.';
showNotification('Failed to load available models', 'error');
}
}
function updateModelBadges(allModels, gpuMap) {
const personas = [
{ selectId: 'model-regular', badgeId: 'model-regular-badge' },
{ selectId: 'model-evil', badgeId: 'model-evil-badge' },
{ selectId: 'model-japanese', badgeId: 'model-japanese-badge' },
];
for (const p of personas) {
const select = document.getElementById(p.selectId);
const badge = document.getElementById(p.badgeId);
if (!select || !badge) continue;
const model = select.value;
const gpus = gpuMap[model];
if (!gpus) {
badge.textContent = '';
badge.style.display = 'none';
} else if (gpus.length === 2 || (gpus.includes('nvidia') && gpus.includes('amd'))) {
badge.textContent = '✅ Both GPUs';
badge.style.background = '#1b5e20';
badge.style.color = '#a5d6a7';
badge.style.display = 'inline';
} else if (gpus.includes('nvidia')) {
badge.textContent = '⚠️ NVIDIA Only';
badge.style.background = '#e65100';
badge.style.color = '#ffcc80';
badge.style.display = 'inline';
} else if (gpus.includes('amd')) {
badge.textContent = '⚠️ AMD Only';
badge.style.background = '#e65100';
badge.style.color = '#ffcc80';
badge.style.display = 'inline';
}
}
}
async function selectModel(persona) {
console.log(`📋 selectModel('${persona}') called`);
try {
const selectId = 'model-' + persona;
const select = document.getElementById(selectId);
if (!select) {
console.warn(`📋 select element #${selectId} not found`);
return;
}
const model = select.value;
if (!model) {
showNotification('Please select a model first', 'error');
return;
}
console.log(`📋 Setting ${persona} model to '${model}'...`);
const result = await apiCall('/models/select', 'POST', {
persona: persona,
model: model,
});
console.log(`📋 /models/select response:`, result);
if (result.success) {
showNotification(`${persona.charAt(0).toUpperCase() + persona.slice(1)} model set to '${model}'`, 'success');
// Update badges
if (availableModelsData) {
updateModelBadges(availableModelsData.all || [], availableModelsData.gpu_map || {});
}
// Refresh language status display (active model may have changed)
refreshLanguageStatus();
} else {
showNotification('Failed to set model: ' + (result.error || 'Unknown error'), 'error');
}
} catch (error) {
console.error('Failed to select model:', error);
showNotification('Failed to set model: ' + error.message, 'error');
}
}
async function refreshModelStatus() {
console.log('📋 refreshModelStatus() called');
try {
const result = await apiCall('/models/status');
console.log('📋 /models/status response:', result);
if (!result.success) return;
// Sync dropdowns with current globals
const personas = [
{ id: 'regular', selectId: 'model-regular' },
{ id: 'evil', selectId: 'model-evil' },
{ id: 'japanese', selectId: 'model-japanese' },
];
for (const p of personas) {
const select = document.getElementById(p.selectId);
if (!select) continue;
const currentModel = result[p.id];
// Check if this value exists in the dropdown
let found = false;
for (const option of select.options) {
if (option.value === currentModel) {
select.value = currentModel;
found = true;
break;
}
}
if (!found && currentModel) {
// Add it if it doesn't exist (e.g., a model that wasn't in the API response)
const option = document.createElement('option');
option.value = currentModel;
option.textContent = currentModel;
select.appendChild(option);
select.value = currentModel;
}
}
// Update badges
if (availableModelsData) {
updateModelBadges(availableModelsData.all || [], availableModelsData.gpu_map || {});
}
} catch (error) {
console.error('Failed to refresh model status:', error);
}
}

View File

@@ -829,15 +829,16 @@ class CatAdapter:
else:
logger.debug("evil_miku_personality already active, skipping toggle")
# Step 3: Switch LLM model to darkidol (the uncensored evil model)
if not await self.set_llm_model("darkidol"):
logger.error("Failed to switch Cat LLM to darkidol")
# Step 3: Switch LLM model to the configured evil model (e.g. darkidol)
evil_model = getattr(globals, "EVIL_TEXT_MODEL", "darkidol")
if not await self.set_llm_model(evil_model):
logger.error(f"Failed to switch Cat LLM to {evil_model}")
success = False
return success
async def switch_to_normal_personality(self) -> bool:
"""Disable evil_miku_personality, enable miku_personality, switch LLM to llama3.1.
"""Disable evil_miku_personality, enable miku_personality, switch LLM to the configured normal model.
Checks current plugin state first to avoid double-toggling.
Returns True if all operations succeed, False if any fail.
@@ -865,9 +866,10 @@ class CatAdapter:
else:
logger.debug("miku_personality already active, skipping toggle")
# Step 3: Switch LLM model back to llama3.1 (normal model)
if not await self.set_llm_model("llama3.1"):
logger.error("Failed to switch Cat LLM to llama3.1")
# Step 3: Switch LLM model to the configured normal model (e.g. llama3.1)
normal_model = getattr(globals, "TEXT_MODEL", "llama3.1")
if not await self.set_llm_model(normal_model):
logger.error(f"Failed to switch Cat LLM to {normal_model}")
success = False
return success