Add settings page, model management, and chat UX improvements
- Settings infrastructure: key-value settings table, GET/PUT API, Pinia store - Configurable assistant name (default "Fable") in settings and LLM system prompt - Model catalog with 18 models in 3 categories (General Purpose, Coding, Uncensored / Creative Writing) with download/select/remove functionality - Move Ollama status indicator from chat views to global nav bar - Chat bubble layout: user messages right-aligned, assistant left-aligned - Floating dark input bar with auto-focus and circular send button - Fix HTML entity rendering (' apostrophe issue in marked/DOMPurify pipeline) - Fix new chat button navigation (fetchConversation before router.push) - Recent chats section on home page with "New Chat" button - Update summary.md with Phase 4.5 changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import type { ModelInfo } from "@/types/settings";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const assistantName = ref("");
|
||||
const saving = ref(false);
|
||||
const saved = ref(false);
|
||||
const pulling = ref<string | null>(null);
|
||||
const deleting = ref<string | null>(null);
|
||||
const confirmDelete = ref<string | 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: "gemma2",
|
||||
description: "Google's Gemma 2 9B — well-rounded model strong in reasoning and conversation.",
|
||||
size: "5.4 GB",
|
||||
bestFor: "Conversation, reasoning, summarization",
|
||||
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: "mythomist",
|
||||
description: "A 7B model specifically tuned for creative and narrative writing including mature themes.",
|
||||
size: "4.1 GB",
|
||||
bestFor: "Creative fiction, narrative writing",
|
||||
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 clean = current.replace(/:latest$/, "");
|
||||
return clean === name || clean === name.replace(/:.*$/, "");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchSettings();
|
||||
assistantName.value = store.assistantName;
|
||||
selectedModel.value = store.defaultModel;
|
||||
store.fetchInstalledModels();
|
||||
});
|
||||
|
||||
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) {
|
||||
pulling.value = name;
|
||||
try {
|
||||
await store.pullModel(name);
|
||||
// Poll for completion
|
||||
const poll = setInterval(async () => {
|
||||
await store.fetchInstalledModels();
|
||||
const installed = new Set(
|
||||
store.installedModels.map((m) => m.replace(/:latest$/, ""))
|
||||
);
|
||||
if (installed.has(name) || installed.has(name.replace(/:.*$/, ""))) {
|
||||
clearInterval(poll);
|
||||
pulling.value = null;
|
||||
}
|
||||
}, 5000);
|
||||
// Stop polling after 10 minutes max
|
||||
setTimeout(() => {
|
||||
clearInterval(poll);
|
||||
if (pulling.value === name) pulling.value = null;
|
||||
}, 600_000);
|
||||
} catch {
|
||||
pulling.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;
|
||||
}
|
||||
</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>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="pulling !== null"
|
||||
>
|
||||
{{ pulling === model.name ? "Pulling..." : "Download" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 700px;
|
||||
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: 8px;
|
||||
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: 6px;
|
||||
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.5rem 1.25rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
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: #22c55e;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 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: 8px;
|
||||
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: 6px;
|
||||
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: 6px;
|
||||
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: 12px;
|
||||
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: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.btn-remove:hover:not(:disabled) {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
.btn-remove:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-confirm-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
.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: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.btn-cancel-delete:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user