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);
+42 -1
View File
@@ -1,4 +1,4 @@
import { ref } from "vue";
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import {
apiGet,
@@ -12,6 +12,7 @@ import type {
ConversationDetail,
Message,
OllamaModel,
OllamaStatus,
} from "@/types/chat";
export const useChatStore = defineStore("chat", () => {
@@ -22,6 +23,14 @@ export const useChatStore = defineStore("chat", () => {
const streaming = ref(false);
const streamingContent = ref("");
const models = ref<OllamaModel[]>([]);
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "ready" | "not_found">("checking");
const defaultModel = ref("");
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
const chatReady = computed(
() => ollamaStatus.value === "available" && modelStatus.value === "ready"
);
async function fetchConversations(limit = 50, offset = 0) {
loading.value = true;
@@ -157,6 +166,31 @@ export const useChatStore = defineStore("chat", () => {
);
}
async function fetchStatus() {
try {
const data = await apiGet<OllamaStatus>("/api/chat/status");
ollamaStatus.value = data.ollama;
modelStatus.value = data.model;
defaultModel.value = data.default_model;
} catch {
ollamaStatus.value = "unavailable";
modelStatus.value = "not_found";
}
}
function startStatusPolling() {
fetchStatus();
stopStatusPolling();
statusPollTimer = setInterval(fetchStatus, 30_000);
}
function stopStatusPolling() {
if (statusPollTimer !== null) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
async function fetchModels() {
try {
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
@@ -174,6 +208,10 @@ export const useChatStore = defineStore("chat", () => {
streaming,
streamingContent,
models,
ollamaStatus,
modelStatus,
defaultModel,
chatReady,
fetchConversations,
createConversation,
fetchConversation,
@@ -183,5 +221,8 @@ export const useChatStore = defineStore("chat", () => {
saveMessageAsNote,
summarizeAsNote,
fetchModels,
fetchStatus,
startStatusPolling,
stopStatusPolling,
};
});
+6
View File
@@ -24,3 +24,9 @@ export interface OllamaModel {
name: string;
size: number;
}
export interface OllamaStatus {
ollama: "available" | "unavailable";
model: "ready" | "not_found";
default_model: string;
}
+62 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick } from "vue";
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { renderMarkdown } from "@/utils/markdown";
@@ -24,13 +24,38 @@ 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}`;
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();
});
watch(convId, async (newId) => {
if (newId) {
await store.fetchConversation(newId);
@@ -159,6 +184,10 @@ 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"
@@ -195,14 +224,14 @@ function onInputKeydown(e: KeyboardEvent) {
<textarea
v-model="messageInput"
@keydown="onInputKeydown"
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
:disabled="store.streaming"
:placeholder="inputPlaceholder"
: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>
@@ -315,6 +344,35 @@ 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;
background: var(--color-bg-secondary);
+25 -2
View File
@@ -1,6 +1,7 @@
import json
import logging
import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.config import Config
@@ -156,10 +157,32 @@ async def summarize_conversation_route(conv_id: int):
return jsonify({"error": str(e)}), 400
@chat_bp.route("/status", methods=["GET"])
async def chat_status_route():
"""Check Ollama availability and model readiness."""
default_model = Config.OLLAMA_MODEL
result = {
"ollama": "unavailable",
"model": "not_found",
"default_model": default_model,
}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
result["ollama"] = "available"
data = resp.json()
model_names = [m["name"] for m in data.get("models", [])]
# Match with or without :latest tag
if default_model in model_names or f"{default_model}:latest" in model_names:
result["model"] = "ready"
except Exception:
logger.debug("Ollama status check failed", exc_info=True)
return jsonify(result)
@chat_bp.route("/models", methods=["GET"])
async def list_models_route():
import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
+8 -6
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-10 — Phase 4 complete (LLM Chat Integration — streaming chat with Ollama, note-aware context, save/summarize as note)
2026-02-10 — Phase 4 + Ollama health check (status indicator on chat views showing Ollama/model readiness)
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -169,7 +169,7 @@ fabledassistant/
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list, status check
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
│ │ └── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description)
│ ├── services/
@@ -198,17 +198,17 @@ fabledassistant/
│ ├── stores/
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady)
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, OllamaModel interfaces
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, OllamaModel, OllamaStatus interfaces
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── utils/
│ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards)
│ ├── views/
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar (conversation list) + message thread + input + summarize
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar (conversation list) + message thread + input + summarize + Ollama status indicator
│ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
@@ -218,7 +218,7 @@ fabledassistant/
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
│ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks + Chat links, chat panel toggle, theme toggle
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop)
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop) + Ollama status indicator
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, role label, "Save as Note" action on assistant messages
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
@@ -264,6 +264,7 @@ fabledassistant/
| POST | `/api/chat/conversations/:id/messages` | Send message + stream LLM response via SSE (body: `{content, context_note_id?}`) |
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note |
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note |
| GET | `/api/chat/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) |
| GET | `/api/chat/models` | List available Ollama models |
## Alembic Migrations
@@ -453,5 +454,6 @@ When adding a new migration, follow these conventions:
- Dedicated `/chat` page with conversation sidebar + message thread
- Slide-out chat panel accessible from any page, auto-includes note/task context
- Auto-pull default model (`llama3.1`) on startup
- Ollama health check: status indicator on chat views (green/yellow/red dot) with 30s polling; disables send when not ready
- Dark/light theme with CSS custom properties
- Ready for Phase 5: Polish & Production Hardening