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:
2026-02-10 21:32:02 -05:00
parent 834fd80640
commit 38b1ac933e
20 changed files with 1257 additions and 236 deletions
+77 -88
View File
@@ -1,16 +1,19 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { onMounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
const route = useRoute();
const router = useRouter();
const store = useChatStore();
const settingsStore = useSettingsStore();
const messageInput = ref("");
const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
const sending = ref(false);
const summarizing = ref(false);
@@ -24,36 +27,17 @@ const streamingRendered = computed(() => {
return renderMarkdown(store.streamingContent);
});
const statusLabel = computed(() => {
if (store.ollamaStatus === "unavailable") return "Ollama unavailable";
if (store.modelStatus === "not_found") return "Model downloading...";
if (store.ollamaStatus === "available" && store.modelStatus === "ready")
return "Connected";
return "Checking...";
});
const statusClass = computed(() => {
if (store.ollamaStatus === "unavailable") return "status-red";
if (store.modelStatus === "not_found") return "status-yellow";
if (store.chatReady) return "status-green";
return "status-yellow";
});
const inputPlaceholder = computed(() => {
if (!store.chatReady) return `Chat unavailable${statusLabel.value}`;
if (!store.chatReady) return "Chat unavailable";
return "Type a message... (Enter to send, Shift+Enter for new line)";
});
onMounted(async () => {
store.startStatusPolling();
await store.fetchConversations();
if (convId.value) {
await store.fetchConversation(convId.value);
}
});
onUnmounted(() => {
store.stopStatusPolling();
nextTick(() => inputEl.value?.focus());
});
watch(convId, async (newId) => {
@@ -63,6 +47,7 @@ watch(convId, async (newId) => {
} else {
store.currentConversation = null;
}
nextTick(() => inputEl.value?.focus());
});
watch(
@@ -84,6 +69,7 @@ async function selectConversation(id: number) {
async function newConversation() {
const conv = await store.createConversation();
await store.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`);
}
@@ -115,6 +101,7 @@ async function sendMessage() {
// Refresh conversation list to show updated title/timestamps
store.fetchConversations();
scrollToBottom();
nextTick(() => inputEl.value?.focus());
}
async function handleSaveAsNote(messageId: number) {
@@ -184,10 +171,6 @@ function onInputKeydown(e: KeyboardEvent) {
<template v-if="store.currentConversation">
<div class="chat-header">
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
<span class="status-indicator" :class="statusClass">
<span class="status-dot"></span>
{{ statusLabel }}
</span>
<button
v-if="store.currentConversation.messages.length"
class="btn-summarize"
@@ -205,13 +188,18 @@ function onInputKeydown(e: KeyboardEvent) {
:message="msg"
@save-as-note="handleSaveAsNote"
/>
<div v-if="store.streaming" class="chat-message role-assistant streaming">
<div class="message-header">
<span class="role-label">Assistant</span>
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
@@ -222,6 +210,7 @@ function onInputKeydown(e: KeyboardEvent) {
<div class="input-area">
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
:placeholder="inputPlaceholder"
@@ -233,7 +222,7 @@ function onInputKeydown(e: KeyboardEvent) {
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
>
Send
&uarr;
</button>
</div>
</template>
@@ -344,34 +333,6 @@ function onInputKeydown(e: KeyboardEvent) {
text-overflow: ellipsis;
white-space: nowrap;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-green .status-dot {
background: #22c55e;
}
.status-yellow .status-dot {
background: #eab308;
animation: pulse-dot 2s infinite;
}
.status-red .status-dot {
background: #ef4444;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.btn-summarize {
padding: 0.3rem 0.75rem;
@@ -396,30 +357,40 @@ function onInputKeydown(e: KeyboardEvent) {
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding: 1rem 1.5rem;
}
.streaming {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.75rem 1rem;
background: var(--color-bg-card);
margin-bottom: 0.5rem;
/* Streaming bubble — matches ChatMessage assistant style */
.chat-message {
display: flex;
margin-bottom: 0.75rem;
}
.streaming .message-header {
.role-assistant {
justify-content: flex-start;
}
.streaming-bubble {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 16px;
border-bottom-left-radius: 4px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
}
.streaming-bubble .message-header {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.streaming .role-label {
font-size: 0.8rem;
.streaming-bubble .role-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.streaming .message-content {
.streaming-bubble .message-content {
font-size: 0.95rem;
line-height: 1.5;
line-height: 1.55;
word-break: break-word;
}
@@ -438,40 +409,52 @@ function onInputKeydown(e: KeyboardEvent) {
50% { opacity: 1; }
}
/* Floating dark input bar */
.input-area {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
margin: 0 1rem 0.75rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: #1c1c1e;
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
.input-area textarea {
flex: 1;
resize: none;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.5rem;
border: none;
border-radius: 12px;
font-family: inherit;
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
}
.input-area textarea:focus {
background: transparent;
color: #fff;
outline: none;
border-color: var(--color-primary);
}
.input-area textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.input-area textarea:disabled {
opacity: 0.5;
}
.btn-send {
padding: 0.5rem 1.25rem;
width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
border-radius: 50%;
cursor: pointer;
font-size: 0.9rem;
align-self: flex-end;
font-size: 1.1rem;
flex-shrink: 0;
}
.btn-send:disabled {
opacity: 0.5;
opacity: 0.35;
cursor: default;
}
@@ -497,5 +480,11 @@ function onInputKeydown(e: KeyboardEvent) {
width: 200px;
min-width: 160px;
}
.messages-container {
padding: 0.75rem;
}
.input-area {
margin: 0 0.5rem 0.5rem;
}
}
</style>