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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -24,3 +24,9 @@ export interface OllamaModel {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface OllamaStatus {
|
||||
ollama: "available" | "unavailable";
|
||||
model: "ready" | "not_found";
|
||||
default_model: string;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user