Add model selection, dashboard chat input, and model warming

- Add GET /api/chat/ps and POST /api/chat/warm endpoints for hot model
  visibility and pre-loading
- Extend PATCH /api/chat/conversations/:id to accept model in addition
  to title
- Add ModelSelector component with hot/cold indicators from Ollama /api/ps
- Add DashboardChatInput component (model selector + note picker + textarea)
  replacing the simple "New Chat" button on the dashboard
- Add model selector dropdown to ChatView header, persisted per-conversation
- Warm default model on dashboard mount via fire-and-forget background task
- Configure Ollama with OLLAMA_MAX_LOADED_MODELS=2 and OLLAMA_KEEP_ALIVE=30m
- Always-visible edit buttons on NoteCard/TaskCard (remove hover-only behavior)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 18:49:06 -05:00
parent 987ec56dc3
commit 953eaf2feb
13 changed files with 710 additions and 306 deletions
+37 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings";
import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import ModelSelector from "@/components/ModelSelector.vue";
import type { Note } from "@/types/note";
const route = useRoute();
@@ -28,6 +29,9 @@ const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Model selection
const selectedModel = ref("");
// Exclude tracking (session-scoped per conversation)
const excludedNoteIds = ref<Set<number>>(new Set());
@@ -71,6 +75,12 @@ onMounted(async () => {
await store.fetchConversations();
if (convId.value) {
await store.fetchConversation(convId.value);
if (store.currentConversation?.model) {
selectedModel.value = store.currentConversation.model;
}
}
if (!selectedModel.value) {
selectedModel.value = store.defaultModel;
}
nextTick(() => inputEl.value?.focus());
});
@@ -101,6 +111,26 @@ watch(convId, async (newId) => {
nextTick(() => inputEl.value?.focus());
});
// Sync selectedModel from loaded conversation
watch(
() => store.currentConversation?.model,
(model) => {
if (model) selectedModel.value = model;
}
);
// Persist model changes to backend
watch(selectedModel, (newModel, oldModel) => {
if (
newModel &&
oldModel &&
newModel !== oldModel &&
store.currentConversation
) {
store.updateConversationModel(store.currentConversation.id, newModel);
}
});
watch(
() => store.streamingContent,
() => scrollToBottom()
@@ -124,7 +154,7 @@ async function selectConversation(id: number) {
}
async function newConversation() {
const conv = await store.createConversation();
const conv = await store.createConversation("", selectedModel.value);
await store.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`);
}
@@ -313,6 +343,10 @@ function excludeAutoNote(noteId: number) {
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<ModelSelector
v-model="selectedModel"
:disabled="store.streaming"
/>
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
<button
v-if="store.currentConversation.messages.length"
@@ -584,6 +618,8 @@ function excludeAutoNote(noteId: number) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.btn-summarize {
+18 -7
View File
@@ -7,6 +7,7 @@ import type { Task, TaskListResponse } from "@/types/task";
import type { Conversation } from "@/types/chat";
import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue";
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import type { TaskStatus } from "@/types/task";
import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat";
@@ -166,11 +167,24 @@ function onStatusToggle(id: number, status: TaskStatus) {
});
}
async function newChat() {
const chatStore = useChatStore();
const conv = await chatStore.createConversation();
const chatStore = useChatStore();
// Warm default model after status fetch
chatStore.fetchStatus().then(() => {
if (chatStore.defaultModel) {
chatStore.warmModel(chatStore.defaultModel);
}
});
async function onChatSubmit(payload: {
content: string;
model: string;
contextNoteId?: number;
}) {
const conv = await chatStore.createConversation("", payload.model);
await chatStore.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`);
await chatStore.sendMessage(payload.content, payload.contextNoteId);
}
</script>
@@ -275,7 +289,7 @@ async function newChat() {
<div v-else class="empty-state">
<p class="empty-text">No chats yet.</p>
</div>
<button class="btn-cta btn-new-chat" @click="newChat">+ New Chat</button>
<DashboardChatInput @submit="onChatSubmit" />
</section>
<section class="section">
@@ -374,9 +388,6 @@ async function newChat() {
color: var(--color-text-muted);
white-space: nowrap;
}
.btn-new-chat {
margin-top: 0.75rem;
}
.empty-state {
text-align: center;
padding: 1.5rem 0;