Add Ollama health check status indicator to chat views

Adds visibility into whether Ollama is reachable and the configured model
is ready before allowing chat. Prevents silent failures when the model is
still downloading or Ollama is unavailable.

- Backend: GET /api/chat/status endpoint checks Ollama /api/tags (5s timeout)
  Returns ollama availability + model readiness + default model name
- Frontend: OllamaStatus type, chat store polling (30s interval)
- ChatView: status dot + label in header (green/yellow/red), disabled send
- ChatPanel: compact status indicator in panel header, disabled send
- summary.md: updated with new endpoint, store changes, and component descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 19:33:42 -05:00
parent e0e1294627
commit 834fd80640
6 changed files with 202 additions and 17 deletions
+59 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import { useChatStore } from "@/stores/chat";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
@@ -26,6 +26,20 @@ watch(
() => scrollToBottom()
);
const statusLabel = computed(() => {
if (store.ollamaStatus === "unavailable") return "Unavailable";
if (store.modelStatus === "not_found") return "Downloading...";
if (store.chatReady) 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";
});
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
@@ -34,6 +48,14 @@ function scrollToBottom() {
});
}
onMounted(() => {
store.startStatusPolling();
});
onUnmounted(() => {
store.stopStatusPolling();
});
async function sendMessage() {
const content = messageInput.value.trim();
if (!content || store.streaming) return;
@@ -74,6 +96,10 @@ function onInputKeydown(e: KeyboardEvent) {
<aside class="chat-panel">
<div class="panel-header">
<h3>Chat</h3>
<span class="status-indicator" :class="statusClass">
<span class="status-dot"></span>
{{ statusLabel }}
</span>
<span v-if="contextNoteId" class="context-indicator">
Note #{{ contextNoteId }}
</span>
@@ -108,14 +134,14 @@ function onInputKeydown(e: KeyboardEvent) {
<textarea
v-model="messageInput"
@keydown="onInputKeydown"
placeholder="Type a message..."
:disabled="store.streaming"
:placeholder="store.chatReady ? 'Type a message...' : statusLabel"
:disabled="store.streaming || !store.chatReady"
rows="2"
></textarea>
<button
class="btn-send"
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
>
Send
</button>
@@ -156,6 +182,35 @@ function onInputKeydown(e: KeyboardEvent) {
font-size: 1rem;
flex: 1;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
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; }
}
.context-indicator {
font-size: 0.8rem;
color: var(--color-primary);