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
@@ -0,0 +1,358 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { apiGet } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import ModelSelector from "@/components/ModelSelector.vue";
import type { Note } from "@/types/note";
const emit = defineEmits<{
submit: [payload: { content: string; model: string; contextNoteId?: number }];
}>();
const store = useChatStore();
const selectedModel = ref(store.defaultModel || "");
const messageInput = ref("");
const inputEl = ref<HTMLTextAreaElement | null>(null);
// Note picker state
const attachedNote = ref<{ id: number; title: string } | null>(null);
const showNotePicker = ref(false);
const noteSearchQuery = ref("");
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Sync default model when store updates
watch(
() => store.defaultModel,
(v) => {
if (!selectedModel.value && v) selectedModel.value = v;
}
);
function onSubmit() {
const content = messageInput.value.trim();
if (!content) return;
emit("submit", {
content,
model: selectedModel.value,
contextNoteId: attachedNote.value?.id,
});
messageInput.value = "";
attachedNote.value = null;
resetTextareaHeight();
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}
function autoResize() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
function resetTextareaHeight() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
}
// Note picker
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value;
if (showNotePicker.value) {
noteSearchQuery.value = "";
noteSearchResults.value = [];
nextTick(() => {
const el = document.querySelector(
".dashboard-chat .note-picker-search"
) as HTMLInputElement;
el?.focus();
});
}
}
function onNoteSearchInput() {
if (noteSearchTimer) clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(async () => {
const q = noteSearchQuery.value.trim();
if (!q) {
noteSearchResults.value = [];
return;
}
noteSearchLoading.value = true;
try {
const data = await apiGet<{ notes: Note[] }>(
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
);
noteSearchResults.value = data.notes.map((n) => ({
id: n.id,
title: n.title,
}));
} catch {
noteSearchResults.value = [];
} finally {
noteSearchLoading.value = false;
}
}, 250);
}
function selectNote(note: { id: number; title: string }) {
attachedNote.value = note;
showNotePicker.value = false;
}
function removeAttachedNote() {
attachedNote.value = null;
}
</script>
<template>
<div class="dashboard-chat">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote">
&times;
</button>
</span>
</div>
<div class="chat-input-bar">
<ModelSelector v-model="selectedModel" :disabled="!store.chatReady" />
<div class="note-picker-wrapper">
<button
class="btn-attach"
@click="toggleNotePicker"
:disabled="!store.chatReady"
title="Attach a note"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
/>
</svg>
</button>
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
class="note-picker-search"
v-model="noteSearchQuery"
@input="onNoteSearchInput"
placeholder="Search notes..."
/>
<div class="note-picker-results">
<div
v-for="note in noteSearchResults"
:key="note.id"
class="note-picker-item"
@click="selectNote(note)"
>
{{ note.title || "Untitled" }}
</div>
<div v-if="noteSearchLoading" class="note-picker-empty">
Searching...
</div>
<div
v-else-if="noteSearchQuery && !noteSearchResults.length"
class="note-picker-empty"
>
No notes found
</div>
</div>
</div>
</div>
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="
store.chatReady
? 'Start a new chat... (Enter to send)'
: 'Chat unavailable'
"
:disabled="!store.chatReady"
rows="1"
></textarea>
<button
class="btn-send"
@click="onSubmit"
:disabled="!messageInput.trim() || !store.chatReady"
>
&uarr;
</button>
</div>
</div>
</template>
<style scoped>
.dashboard-chat {
margin-top: 0.75rem;
}
.attached-note {
padding: 0.25rem 0;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
}
.attached-note-remove:hover {
color: #fff;
}
.chat-input-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: var(--color-input-bar-bg);
border-radius: 20px;
box-shadow: 0 2px 12px var(--color-shadow);
}
.chat-input-bar textarea {
flex: 1;
resize: none;
padding: 0.4rem 0.5rem;
border: none;
border-radius: 12px;
font-family: inherit;
font-size: 0.95rem;
background: transparent;
color: var(--color-input-bar-text);
outline: none;
max-height: 120px;
overflow-y: auto;
}
.chat-input-bar textarea::placeholder {
color: var(--color-input-bar-placeholder);
}
.chat-input-bar textarea:disabled {
opacity: 0.5;
}
/* Note picker */
.note-picker-wrapper {
position: relative;
}
.btn-attach {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.btn-attach:hover {
opacity: 1;
}
.btn-attach:disabled {
opacity: 0.3;
cursor: default;
}
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 280px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 10;
overflow: hidden;
}
.note-picker-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results {
max-height: 200px;
overflow-y: auto;
}
.note-picker-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover {
background: var(--color-bg-secondary);
}
.note-picker-empty {
padding: 0.5rem 0.75rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.btn-send {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 1.1rem;
flex-shrink: 0;
}
.btn-send:disabled {
opacity: 0.35;
cursor: default;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { onMounted, computed } from "vue";
import { useChatStore } from "@/stores/chat";
defineProps<{
modelValue: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const store = useChatStore();
function normalize(name: string): string {
return name.replace(/:latest$/, "");
}
const hotNames = computed(() => {
return new Set(store.runningModels.map((m) => normalize(m.name)));
});
function isHot(modelName: string): boolean {
return hotNames.value.has(normalize(modelName));
}
function onChange(e: Event) {
const val = (e.target as HTMLSelectElement).value;
emit("update:modelValue", val);
}
onMounted(async () => {
if (!store.models.length) store.fetchModels();
store.fetchRunningModels();
});
</script>
<template>
<select
class="model-selector"
:value="modelValue"
:disabled="disabled"
@change="onChange"
>
<option v-if="!modelValue" value="" disabled>Select model</option>
<option
v-for="m in store.models"
:key="m.name"
:value="m.name"
>
{{ isHot(m.name) ? "\u2B24 " : "\u25CB " }}{{ m.name }}
</option>
</select>
</template>
<style scoped>
.model-selector {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.model-selector:disabled {
opacity: 0.5;
cursor: default;
}
.model-selector:focus {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
</style>
+5 -14
View File
@@ -68,28 +68,19 @@ function goEdit() {
}
.btn-edit {
flex-shrink: 0;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
background: transparent;
color: var(--color-text-muted);
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.note-card:hover .btn-edit {
opacity: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.btn-edit:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
@media (hover: none) {
.btn-edit {
opacity: 1;
}
}
.note-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
+5 -14
View File
@@ -93,28 +93,19 @@ function isOverdue(): boolean {
}
.btn-edit {
flex-shrink: 0;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
background: transparent;
color: var(--color-text-muted);
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.task-card:hover .btn-edit {
opacity: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.btn-edit:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
@media (hover: none) {
.btn-edit {
opacity: 1;
}
}
.task-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
+42
View File
@@ -15,6 +15,7 @@ import type {
Message,
OllamaModel,
OllamaStatus,
RunningModel,
SendMessageResponse,
} from "@/types/chat";
@@ -27,6 +28,7 @@ export const useChatStore = defineStore("chat", () => {
const streamingContent = ref("");
const lastContextMeta = ref<ContextMeta | null>(null);
const models = ref<OllamaModel[]>([]);
const runningModels = ref<RunningModel[]>([]);
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "ready" | "not_found">("checking");
const defaultModel = ref("");
@@ -308,6 +310,42 @@ export const useChatStore = defineStore("chat", () => {
}
}
async function fetchRunningModels() {
try {
const data = await apiGet<{ models: RunningModel[] }>("/api/chat/ps");
runningModels.value = data.models;
} catch {
runningModels.value = [];
}
}
async function warmModel(model: string) {
try {
await apiPost("/api/chat/warm", { model });
} catch {
// Warming is best-effort
}
}
async function updateConversationModel(id: number, model: string) {
try {
const updated = await apiPatch<Conversation>(
`/api/chat/conversations/${id}`,
{ model }
);
const idx = conversations.value.findIndex((c) => c.id === id);
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...updated };
}
if (currentConversation.value?.id === id) {
currentConversation.value.model = updated.model;
}
} catch (e) {
useToastStore().show("Failed to update model", "error");
throw e;
}
}
return {
conversations,
currentConversation,
@@ -317,6 +355,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent,
lastContextMeta,
models,
runningModels,
ollamaStatus,
modelStatus,
defaultModel,
@@ -331,6 +370,9 @@ export const useChatStore = defineStore("chat", () => {
saveMessageAsNote,
summarizeAsNote,
fetchModels,
fetchRunningModels,
warmModel,
updateConversationModel,
fetchStatus,
startStatusPolling,
stopStatusPolling,
+7
View File
@@ -31,6 +31,13 @@ export interface OllamaModel {
size: number;
}
export interface RunningModel {
name: string;
size: number;
size_vram: number;
expires_at: string;
}
export interface ContextMeta {
context_note_id: number | null;
context_note_title: string | null;
+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;