f77b029943
- Registration auto-closes after first user; admin can toggle from /admin/users - Admin user management view with user list and delete - Password confirmation on registration form - Password change in Settings (PUT /api/auth/password) - Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag - Startup warning when SECRET_KEY is default - Production deployment docs: reverse proxy, rate limiting, CSP headers - Fix assist prompt to preserve markdown headings in target sections - Simplify prod compose networking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
847 lines
23 KiB
Vue
847 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { useSettingsStore } from "@/stores/settings";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import { apiPut } from "@/api/client";
|
|
import type { ModelInfo } from "@/types/settings";
|
|
|
|
const store = useSettingsStore();
|
|
const authStore = useAuthStore();
|
|
const toastStore = useToastStore();
|
|
const assistantName = ref("");
|
|
const currentPassword = ref("");
|
|
const newPassword = ref("");
|
|
const confirmNewPassword = ref("");
|
|
const changingPassword = ref(false);
|
|
const saving = ref(false);
|
|
const saved = ref(false);
|
|
const pullProgress = ref<{ model: string; percent: number } | null>(null);
|
|
const deleting = ref<string | null>(null);
|
|
const confirmDelete = ref<string | null>(null);
|
|
const exporting = ref(false);
|
|
const restoring = ref(false);
|
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
|
|
|
const MODEL_CATALOG: ModelInfo[] = [
|
|
// — General Purpose —
|
|
{
|
|
name: "llama3.1",
|
|
description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following.",
|
|
size: "4.7 GB",
|
|
bestFor: "General chat, writing, Q&A",
|
|
category: "General Purpose",
|
|
},
|
|
{
|
|
name: "llama3.1:70b",
|
|
description: "Meta's Llama 3.1 70B — significantly more capable, better reasoning and nuance.",
|
|
size: "40 GB",
|
|
bestFor: "Complex reasoning, detailed analysis",
|
|
category: "General Purpose",
|
|
},
|
|
{
|
|
name: "mistral",
|
|
description: "Mistral 7B — fast and efficient with strong performance for its size.",
|
|
size: "4.1 GB",
|
|
bestFor: "Fast responses, general tasks",
|
|
category: "General Purpose",
|
|
},
|
|
{
|
|
name: "qwen2.5",
|
|
description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills.",
|
|
size: "4.7 GB",
|
|
bestFor: "Multilingual, code, math",
|
|
category: "General Purpose",
|
|
},
|
|
{
|
|
name: "phi3",
|
|
description: "Microsoft Phi-3 Mini — compact model with surprising capability for its size.",
|
|
size: "2.3 GB",
|
|
bestFor: "Light tasks, low resource usage",
|
|
category: "General Purpose",
|
|
},
|
|
{
|
|
name: "neural-chat",
|
|
description: "Intel's fine-tune optimized for natural conversation. Lighter filtering than base models.",
|
|
size: "4.1 GB",
|
|
bestFor: "Natural conversation, general tasks",
|
|
category: "General Purpose",
|
|
},
|
|
{
|
|
name: "yi",
|
|
description: "01.AI's Yi 6B — Chinese-developed model, more permissive on creative content.",
|
|
size: "3.5 GB",
|
|
bestFor: "Creative content, multilingual",
|
|
category: "General Purpose",
|
|
},
|
|
{
|
|
name: "command-r",
|
|
description: "Cohere's Command R 35B — enterprise-grade model with light content filtering.",
|
|
size: "20 GB",
|
|
bestFor: "RAG, conversation, creative tasks",
|
|
category: "General Purpose",
|
|
},
|
|
// — Coding —
|
|
{
|
|
name: "codellama",
|
|
description: "Meta's Code Llama — specialized for code generation and understanding.",
|
|
size: "3.8 GB",
|
|
bestFor: "Code generation, debugging, technical docs",
|
|
category: "Coding",
|
|
},
|
|
{
|
|
name: "deepseek-coder-v2",
|
|
description: "DeepSeek Coder V2 — state-of-the-art coding model with strong math ability.",
|
|
size: "8.9 GB",
|
|
bestFor: "Code, math, technical problem solving",
|
|
category: "Coding",
|
|
},
|
|
// — Uncensored / Creative Writing —
|
|
{
|
|
name: "dolphin-mistral",
|
|
description: "Eric Hartford's Dolphin fine-tune of Mistral 7B. Safety/refusal data removed from training.",
|
|
size: "4.1 GB",
|
|
bestFor: "Uncensored general chat, creative writing",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
{
|
|
name: "dolphin-llama3",
|
|
description: "Dolphin fine-tune of Llama 3 8B. Uncensored training on a stronger base model.",
|
|
size: "4.7 GB",
|
|
bestFor: "Uncensored chat, strong reasoning",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
{
|
|
name: "dolphin-mixtral",
|
|
description: "Dolphin fine-tune of Mixtral 8x7B MoE. Uncensored with mixture-of-experts architecture.",
|
|
size: "26 GB",
|
|
bestFor: "Uncensored + high capability (needs RAM)",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
{
|
|
name: "nous-hermes2",
|
|
description: "Nous Research's Hermes 2 — trained on diverse synthetic data with minimal refusal behavior.",
|
|
size: "4.1 GB",
|
|
bestFor: "Instruction following, few refusals",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
{
|
|
name: "openhermes",
|
|
description: "Community fine-tune focused on helpfulness. Based on Mistral 7B without refusal patterns.",
|
|
size: "4.1 GB",
|
|
bestFor: "Helpful assistant, minimal filtering",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
{
|
|
name: "nollama/mythomax-l2-13b",
|
|
description: "MythoMax L2 13B — a merge of multiple fine-tunes for creative and narrative writing with strong coherence.",
|
|
size: "7.4 GB",
|
|
bestFor: "Creative fiction, roleplay, narrative writing",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
{
|
|
name: "mattw/mythalion",
|
|
description: "Mythalion 13B — a Gryphe merge combining Mythologic and Pygmalion for expressive creative output.",
|
|
size: "7.4 GB",
|
|
bestFor: "Creative writing, character dialogue",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
{
|
|
name: "samantha-mistral",
|
|
description: "Eric Hartford's Samantha personality model. Designed as a helpful companion without refusals.",
|
|
size: "4.1 GB",
|
|
bestFor: "Companion chat, unrestricted conversation",
|
|
category: "Uncensored / Creative Writing",
|
|
},
|
|
];
|
|
|
|
const selectedModel = ref("");
|
|
|
|
const modelStatuses = computed(() => {
|
|
const installed = new Set(
|
|
store.installedModels.map((m) => m.replace(/:latest$/, ""))
|
|
);
|
|
return MODEL_CATALOG.map((m) => ({
|
|
...m,
|
|
installed: installed.has(m.name) || installed.has(m.name.replace(/:.*$/, "")),
|
|
active: isActiveModel(m.name),
|
|
}));
|
|
});
|
|
|
|
const categories = computed(() => {
|
|
const cats: string[] = [];
|
|
for (const m of modelStatuses.value) {
|
|
if (!cats.includes(m.category)) cats.push(m.category);
|
|
}
|
|
return cats;
|
|
});
|
|
|
|
function modelsInCategory(category: string) {
|
|
return modelStatuses.value.filter((m) => m.category === category);
|
|
}
|
|
|
|
function isActiveModel(name: string): boolean {
|
|
const current = selectedModel.value || store.defaultModel;
|
|
if (!current) return false;
|
|
const cleanCurrent = current.replace(/:latest$/, "");
|
|
const cleanName = name.replace(/:latest$/, "");
|
|
return cleanCurrent === cleanName;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await store.fetchSettings();
|
|
assistantName.value = store.assistantName;
|
|
selectedModel.value = store.defaultModel;
|
|
store.fetchInstalledModels();
|
|
});
|
|
|
|
async function changePassword() {
|
|
if (newPassword.value !== confirmNewPassword.value) {
|
|
toastStore.show("New passwords do not match", "error");
|
|
return;
|
|
}
|
|
changingPassword.value = true;
|
|
try {
|
|
await apiPut("/api/auth/password", {
|
|
current_password: currentPassword.value,
|
|
new_password: newPassword.value,
|
|
});
|
|
toastStore.show("Password changed successfully");
|
|
currentPassword.value = "";
|
|
newPassword.value = "";
|
|
confirmNewPassword.value = "";
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const body = (e as { body?: { error?: string } }).body;
|
|
toastStore.show(body?.error || "Failed to change password", "error");
|
|
} else {
|
|
toastStore.show("Failed to change password", "error");
|
|
}
|
|
} finally {
|
|
changingPassword.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveAssistant() {
|
|
saving.value = true;
|
|
saved.value = false;
|
|
try {
|
|
await store.updateSettings({ assistant_name: assistantName.value.trim() || "Fable" });
|
|
saved.value = true;
|
|
setTimeout(() => (saved.value = false), 2000);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function selectModel(name: string) {
|
|
selectedModel.value = name;
|
|
saving.value = true;
|
|
try {
|
|
await store.updateSettings({ default_model: name });
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function pullModel(name: string) {
|
|
pullProgress.value = { model: name, percent: 0 };
|
|
try {
|
|
await store.pullModel(name, (data) => {
|
|
if (data.error) {
|
|
pullProgress.value = null;
|
|
return;
|
|
}
|
|
const completed = data.completed as number | undefined;
|
|
const total = data.total as number | undefined;
|
|
if (completed != null && total && total > 0) {
|
|
pullProgress.value = {
|
|
model: name,
|
|
percent: Math.round((completed / total) * 100),
|
|
};
|
|
}
|
|
});
|
|
await store.fetchInstalledModels();
|
|
} catch {
|
|
// error already handled
|
|
} finally {
|
|
pullProgress.value = null;
|
|
}
|
|
}
|
|
|
|
async function removeModel(name: string) {
|
|
if (confirmDelete.value !== name) {
|
|
confirmDelete.value = name;
|
|
return;
|
|
}
|
|
confirmDelete.value = null;
|
|
deleting.value = name;
|
|
try {
|
|
await store.deleteModel(name);
|
|
// If the deleted model was active, clear the selection
|
|
if (isActiveModel(name)) {
|
|
selectedModel.value = "";
|
|
}
|
|
} finally {
|
|
deleting.value = null;
|
|
}
|
|
}
|
|
|
|
function cancelDelete() {
|
|
confirmDelete.value = null;
|
|
}
|
|
|
|
async function exportData(scope: "user" | "full") {
|
|
exporting.value = true;
|
|
try {
|
|
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
|
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
|
}
|
|
const data = await res.json();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
toastStore.show("Backup downloaded");
|
|
} catch (e) {
|
|
toastStore.show("Export failed: " + (e as Error).message, "error");
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
function triggerRestoreUpload() {
|
|
restoreFileInput.value?.click();
|
|
}
|
|
|
|
async function handleRestoreFile(event: Event) {
|
|
const file = (event.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
restoring.value = true;
|
|
try {
|
|
const text = await file.text();
|
|
const data = JSON.parse(text);
|
|
const res = await fetch("/api/admin/restore", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
|
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
|
}
|
|
const result = await res.json();
|
|
toastStore.show(
|
|
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
|
|
);
|
|
} catch (e) {
|
|
toastStore.show("Restore failed: " + (e as Error).message, "error");
|
|
} finally {
|
|
restoring.value = false;
|
|
// Reset file input
|
|
if (restoreFileInput.value) restoreFileInput.value.value = "";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="settings-page">
|
|
<h1>Settings</h1>
|
|
|
|
<section class="settings-section">
|
|
<h2>Assistant</h2>
|
|
<div class="field">
|
|
<label for="assistant-name">Assistant Name</label>
|
|
<input
|
|
id="assistant-name"
|
|
v-model="assistantName"
|
|
type="text"
|
|
placeholder="Fable"
|
|
class="input"
|
|
/>
|
|
<p class="field-hint">
|
|
The name used for the AI assistant in chat messages and LLM context.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button class="btn-save" @click="saveAssistant" :disabled="saving">
|
|
{{ saving ? "Saving..." : "Save" }}
|
|
</button>
|
|
<span v-if="saved" class="saved-msg">Saved!</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="settings-section">
|
|
<h2>Change Password</h2>
|
|
<div class="field">
|
|
<label for="current-password">Current Password</label>
|
|
<input
|
|
id="current-password"
|
|
v-model="currentPassword"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div class="field">
|
|
<label for="new-password">New Password</label>
|
|
<input
|
|
id="new-password"
|
|
v-model="newPassword"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
class="input"
|
|
/>
|
|
<p class="field-hint">Must be at least 8 characters</p>
|
|
</div>
|
|
<div class="field">
|
|
<label for="confirm-new-password">Confirm New Password</label>
|
|
<input
|
|
id="confirm-new-password"
|
|
v-model="confirmNewPassword"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
class="input"
|
|
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
|
|
/>
|
|
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
|
|
Passwords do not match
|
|
</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button
|
|
class="btn-save"
|
|
@click="changePassword"
|
|
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
|
>
|
|
{{ changingPassword ? "Changing..." : "Change Password" }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="settings-section">
|
|
<h2>Model</h2>
|
|
<p class="section-desc">
|
|
Choose which LLM model to use for chat. Models need to be downloaded before use.
|
|
</p>
|
|
|
|
<div v-for="cat in categories" :key="cat" class="model-category">
|
|
<h3 class="category-label">{{ cat }}</h3>
|
|
<div class="model-list">
|
|
<div
|
|
v-for="model in modelsInCategory(cat)"
|
|
:key="model.name"
|
|
class="model-card"
|
|
:class="{ active: model.active }"
|
|
>
|
|
<div class="model-info">
|
|
<div class="model-name-row">
|
|
<span class="model-name">{{ model.name }}</span>
|
|
<span class="model-size">{{ model.size }}</span>
|
|
</div>
|
|
<p class="model-desc">{{ model.description }}</p>
|
|
<p class="model-best-for">
|
|
<strong>Best for:</strong> {{ model.bestFor }}
|
|
</p>
|
|
</div>
|
|
<div class="model-actions">
|
|
<template v-if="model.installed">
|
|
<button
|
|
v-if="!model.active"
|
|
class="btn-select"
|
|
@click="selectModel(model.name)"
|
|
:disabled="saving"
|
|
>
|
|
Select
|
|
</button>
|
|
<span v-else class="active-badge">Active</span>
|
|
<template v-if="confirmDelete === model.name">
|
|
<button
|
|
class="btn-confirm-delete"
|
|
@click="removeModel(model.name)"
|
|
:disabled="deleting !== null"
|
|
>
|
|
{{ deleting === model.name ? "Removing..." : "Confirm" }}
|
|
</button>
|
|
<button class="btn-cancel-delete" @click="cancelDelete">
|
|
Cancel
|
|
</button>
|
|
</template>
|
|
<button
|
|
v-else
|
|
class="btn-remove"
|
|
@click="removeModel(model.name)"
|
|
:disabled="deleting !== null || model.active"
|
|
:title="model.active ? 'Cannot remove the active model' : 'Remove model'"
|
|
>
|
|
Remove
|
|
</button>
|
|
</template>
|
|
<button
|
|
v-else
|
|
class="btn-pull"
|
|
@click="pullModel(model.name)"
|
|
:disabled="pullProgress !== null"
|
|
>
|
|
{{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="settings-section">
|
|
<h2>Data</h2>
|
|
<p class="section-desc">Export your data or restore from a backup.</p>
|
|
|
|
<div class="data-actions">
|
|
<button
|
|
class="btn-export"
|
|
@click="exportData('user')"
|
|
:disabled="exporting"
|
|
>
|
|
{{ exporting ? "Exporting..." : "Export My Data" }}
|
|
</button>
|
|
|
|
<template v-if="authStore.isAdmin">
|
|
<button
|
|
class="btn-export"
|
|
@click="exportData('full')"
|
|
:disabled="exporting"
|
|
>
|
|
{{ exporting ? "Exporting..." : "Export Full Backup" }}
|
|
</button>
|
|
<button
|
|
class="btn-restore"
|
|
@click="triggerRestoreUpload"
|
|
:disabled="restoring"
|
|
>
|
|
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
|
</button>
|
|
<input
|
|
ref="restoreFileInput"
|
|
type="file"
|
|
accept=".json"
|
|
class="hidden-file-input"
|
|
@change="handleRestoreFile"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.settings-page {
|
|
max-width: 960px;
|
|
margin: 2rem auto;
|
|
padding: 0 1rem;
|
|
}
|
|
.settings-page h1 {
|
|
margin: 0 0 1.5rem;
|
|
}
|
|
.settings-section {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1.25rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.settings-section h2 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
.section-desc {
|
|
margin: 0 0 1rem;
|
|
font-size: 0.9rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.field {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.field label {
|
|
display: block;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.35rem;
|
|
color: var(--color-text);
|
|
}
|
|
.input {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.95rem;
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
box-sizing: border-box;
|
|
}
|
|
.input:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
}
|
|
.field-hint {
|
|
margin: 0.35rem 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
.btn-save {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.btn-save:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.btn-save:hover:not(:disabled) {
|
|
opacity: 0.9;
|
|
}
|
|
.saved-msg {
|
|
color: var(--color-success);
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
}
|
|
.input-error {
|
|
border-color: var(--color-danger);
|
|
}
|
|
.input-error:focus {
|
|
border-color: var(--color-danger);
|
|
}
|
|
.error-hint {
|
|
margin: 0.35rem 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
/* Model list */
|
|
.model-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
.model-card {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--color-bg);
|
|
transition: border-color 0.15s;
|
|
}
|
|
.model-card.active {
|
|
border-color: var(--color-primary);
|
|
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg));
|
|
}
|
|
.model-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.model-name-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
.model-name {
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
font-family: monospace;
|
|
}
|
|
.model-size {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
.model-desc {
|
|
margin: 0 0 0.25rem;
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-secondary);
|
|
line-height: 1.4;
|
|
}
|
|
.model-best-for {
|
|
margin: 0;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.model-best-for strong {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.btn-select {
|
|
padding: 0.35rem 0.9rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
}
|
|
.btn-select:hover:not(:disabled) {
|
|
opacity: 0.9;
|
|
}
|
|
.btn-select:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.btn-pull {
|
|
padding: 0.35rem 0.9rem;
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
}
|
|
.btn-pull:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.btn-pull:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.active-badge {
|
|
padding: 0.25rem 0.75rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border-radius: var(--radius-lg);
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Category headers */
|
|
.model-category {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.model-category:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.category-label {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
/* Model actions layout */
|
|
.model-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
/* Remove button */
|
|
.btn-remove {
|
|
padding: 0.25rem 0.6rem;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}
|
|
.btn-remove:hover:not(:disabled) {
|
|
border-color: var(--color-danger);
|
|
color: var(--color-danger);
|
|
}
|
|
.btn-remove:disabled {
|
|
opacity: 0.4;
|
|
cursor: default;
|
|
}
|
|
.btn-confirm-delete {
|
|
padding: 0.25rem 0.6rem;
|
|
background: var(--color-danger);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
.btn-confirm-delete:hover:not(:disabled) {
|
|
filter: brightness(0.9);
|
|
}
|
|
.btn-confirm-delete:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.btn-cancel-delete {
|
|
padding: 0.25rem 0.6rem;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}
|
|
.btn-cancel-delete:hover {
|
|
color: var(--color-text);
|
|
border-color: var(--color-text-muted);
|
|
}
|
|
|
|
/* Data section */
|
|
.data-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.btn-export {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.btn-export:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.btn-export:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.btn-restore {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.btn-restore:hover:not(:disabled) {
|
|
border-color: var(--color-warning);
|
|
color: var(--color-warning);
|
|
}
|
|
.btn-restore:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.hidden-file-input {
|
|
display: none;
|
|
}
|
|
</style>
|